Add tests for featured thumbnail generation; apply Pint formatting and related edits
This commit is contained in:
@@ -5,13 +5,16 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
@@ -139,8 +142,8 @@ final class AcademyFeatureTest extends TestCase
|
||||
->where('item.prompt', null)
|
||||
->where('item.negative_prompt', null));
|
||||
|
||||
$version = app(\App\Http\Middleware\HandleInertiaRequests::class)
|
||||
->version(\Illuminate\Http\Request::create(route('academy.prompts.show', ['slug' => $prompt->slug]), 'GET'));
|
||||
$version = app(HandleInertiaRequests::class)
|
||||
->version(Request::create(route('academy.prompts.show', ['slug' => $prompt->slug]), 'GET'));
|
||||
|
||||
$this->withHeaders([
|
||||
'X-Inertia' => 'true',
|
||||
@@ -180,6 +183,96 @@ final class AcademyFeatureTest extends TestCase
|
||||
->where('item.prompt', 'VISIBLE PREMIUM PROMPT'));
|
||||
}
|
||||
|
||||
public function test_public_lesson_payload_includes_active_ai_comparison_block_and_hides_inactive_results(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Free Lesson With Comparison',
|
||||
'slug' => 'free-lesson-with-comparison',
|
||||
'excerpt' => 'Visible to guests.',
|
||||
'content' => 'Free lesson content',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
$block = AcademyLessonBlock::query()->create([
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'payload' => [
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'intro' => 'We used the same prompt in multiple tools.',
|
||||
'prompt' => 'A peaceful fantasy forest wallpaper.',
|
||||
'negative_prompt' => 'text, watermark',
|
||||
'aspect_ratio' => '16:9',
|
||||
'criteria' => ['Composition', 'Lighting'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
AcademyAiComparisonResult::query()->create([
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'strengths' => 'Strong composition',
|
||||
'score' => 9,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
AcademyAiComparisonResult::query()->create([
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'Google',
|
||||
'model_name' => 'Gemini',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example-2.webp',
|
||||
'score' => 7,
|
||||
'sort_order' => 1,
|
||||
'active' => false,
|
||||
]);
|
||||
|
||||
$this->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Show')
|
||||
->where('item.blocks.0.payload.prompt', 'A peaceful fantasy forest wallpaper.')
|
||||
->has('item.blocks.0.comparison_results', 1)
|
||||
->where('item.blocks.0.comparison_results.0.model_name', 'ChatGPT Images'));
|
||||
}
|
||||
|
||||
public function test_public_lesson_with_sparse_ai_comparison_block_still_renders_payload(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Sparse Comparison Lesson',
|
||||
'slug' => 'sparse-comparison-lesson',
|
||||
'excerpt' => 'Sparse block test.',
|
||||
'content' => 'Free lesson content',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
AcademyLessonBlock::query()->create([
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Prompt only block',
|
||||
'payload' => [
|
||||
'title' => 'Prompt only block',
|
||||
'prompt' => 'A fantasy forest at sunrise.',
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->has('item.blocks', 1)
|
||||
->where('item.blocks.0.payload.prompt', 'A fantasy forest at sunrise.')
|
||||
->has('item.blocks.0.comparison_results', 0));
|
||||
}
|
||||
|
||||
public function test_logged_in_user_can_mark_lesson_completed(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
@@ -511,4 +604,4 @@ final class AcademyFeatureTest extends TestCase
|
||||
'workflow_notes' => 'Workflow two',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -128,4 +131,230 @@ final class AcademyAdminTest extends TestCase
|
||||
$this->assertNull(Cache::get('academy.home'));
|
||||
$this->assertNull(Cache::get('academy.categories.lesson'));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_can_create_a_lesson_with_ai_comparison_block(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$response = $this->actingAs($admin)
|
||||
->post(route('admin.academy.lessons.store'), [
|
||||
'title' => 'AI Comparison Lesson',
|
||||
'slug' => 'ai-comparison-lesson',
|
||||
'excerpt' => 'Testing comparison block creation.',
|
||||
'content' => '<p>Lesson body.</p>',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'cover_image' => '',
|
||||
'video_url' => '',
|
||||
'reading_minutes' => 5,
|
||||
'featured' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'payload' => [
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'intro' => 'Compare multiple tools.',
|
||||
'prompt' => 'A peaceful fantasy forest wallpaper.',
|
||||
'negative_prompt' => 'text, watermark',
|
||||
'aspect_ratio' => '16:9',
|
||||
'criteria' => ['Composition', 'Lighting'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
'comparison_results' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$lesson = AcademyLesson::query()->where('slug', 'ai-comparison-lesson')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||
|
||||
$this->assertDatabaseHas('academy_lesson_blocks', [
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_add_ai_comparison_result_to_existing_lesson(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Existing Lesson',
|
||||
'slug' => 'existing-lesson',
|
||||
'content' => 'Body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [
|
||||
'title' => $lesson->title,
|
||||
'slug' => $lesson->slug,
|
||||
'excerpt' => '',
|
||||
'content' => $lesson->content,
|
||||
'difficulty' => $lesson->difficulty,
|
||||
'access_level' => $lesson->access_level,
|
||||
'lesson_type' => $lesson->lesson_type,
|
||||
'cover_image' => '',
|
||||
'video_url' => '',
|
||||
'reading_minutes' => 5,
|
||||
'featured' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'payload' => [
|
||||
'title' => 'Same Prompt, Different AI Models',
|
||||
'intro' => 'Compare multiple tools.',
|
||||
'prompt' => 'A peaceful fantasy forest wallpaper.',
|
||||
'negative_prompt' => '',
|
||||
'aspect_ratio' => '16:9',
|
||||
'criteria' => ['Composition'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
'comparison_results' => [
|
||||
[
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'thumb_path' => 'academy/lessons/body/aa/bb/example-thumb.webp',
|
||||
'settings' => 'Default quality',
|
||||
'strengths' => 'Strong composition',
|
||||
'weaknesses' => 'Slightly over-polished',
|
||||
'best_for' => 'Wallpaper concepts',
|
||||
'score' => 9,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]));
|
||||
|
||||
$block = AcademyLessonBlock::query()->where('lesson_id', $lesson->id)->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('academy_ai_comparison_results', [
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'score' => 9,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ai_comparison_score_must_stay_in_range(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Validation Lesson',
|
||||
'slug' => 'validation-lesson',
|
||||
'content' => 'Body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->from(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]))
|
||||
->actingAs($admin)
|
||||
->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [
|
||||
'title' => $lesson->title,
|
||||
'slug' => $lesson->slug,
|
||||
'excerpt' => '',
|
||||
'content' => $lesson->content,
|
||||
'difficulty' => $lesson->difficulty,
|
||||
'access_level' => $lesson->access_level,
|
||||
'lesson_type' => $lesson->lesson_type,
|
||||
'cover_image' => '',
|
||||
'video_url' => '',
|
||||
'reading_minutes' => 5,
|
||||
'featured' => false,
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute()->toDateTimeString(),
|
||||
'seo_title' => '',
|
||||
'seo_description' => '',
|
||||
'blocks' => [
|
||||
[
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Invalid score block',
|
||||
'payload' => [
|
||||
'title' => 'Invalid score block',
|
||||
'prompt' => 'Prompt',
|
||||
'criteria' => ['Composition'],
|
||||
],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
'comparison_results' => [
|
||||
[
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'score' => 11,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson]))
|
||||
->assertSessionHasErrors(['blocks.0.comparison_results.0.score']);
|
||||
}
|
||||
|
||||
public function test_lesson_delete_soft_deletes_ai_comparison_children(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Delete Lesson',
|
||||
'slug' => 'delete-lesson',
|
||||
'content' => 'Body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
$block = AcademyLessonBlock::query()->create([
|
||||
'lesson_id' => $lesson->id,
|
||||
'type' => 'ai_comparison',
|
||||
'title' => 'Delete Block',
|
||||
'payload' => ['title' => 'Delete Block', 'prompt' => 'Prompt'],
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
$result = AcademyAiComparisonResult::query()->create([
|
||||
'lesson_block_id' => $block->id,
|
||||
'provider' => 'OpenAI',
|
||||
'model_name' => 'ChatGPT Images',
|
||||
'image_path' => 'academy/lessons/body/aa/bb/example.webp',
|
||||
'score' => 8,
|
||||
'sort_order' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete(route('admin.academy.lessons.destroy', ['academyLesson' => $lesson]))
|
||||
->assertRedirect(route('admin.academy.lessons.index'));
|
||||
|
||||
$this->assertSoftDeleted('academy_lessons', ['id' => $lesson->id]);
|
||||
$this->assertSoftDeleted('academy_lesson_blocks', ['id' => $block->id]);
|
||||
$this->assertSoftDeleted('academy_ai_comparison_results', ['id' => $result->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateFeaturedArtworkThumbnailsJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeFeaturedArtworkSource(Artwork $artwork, string $root): string
|
||||
{
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$directory = $root.DIRECTORY_SEPARATOR.substr($hash, 0, 2).DIRECTORY_SEPARATOR.substr($hash, 2, 2);
|
||||
|
||||
File::ensureDirectoryExists($directory);
|
||||
|
||||
$path = $directory.DIRECTORY_SEPARATOR.$hash.'.png';
|
||||
$file = UploadedFile::fake()->image($hash.'.png', 1800, 1200);
|
||||
file_put_contents($path, $file->get());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
function insertFeaturedArtworkRow(Artwork $artwork): void
|
||||
{
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 500,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'force_hero' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
test('featured thumbnail command generates dedicated featured variants', function () {
|
||||
Storage::fake('s3');
|
||||
|
||||
$localRoot = storage_path('framework/testing/featured-originals-command');
|
||||
$backupRoot = storage_path('framework/testing/featured-originals-command-backup');
|
||||
|
||||
File::deleteDirectory($localRoot);
|
||||
File::deleteDirectory($backupRoot);
|
||||
File::ensureDirectoryExists($localRoot);
|
||||
File::ensureDirectoryExists($backupRoot);
|
||||
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'uploads.local_originals_root' => $localRoot,
|
||||
'uploads.readonly_backup_originals_root' => $backupRoot,
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Featured Command Artwork',
|
||||
'hash' => str_repeat('b', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
insertFeaturedArtworkRow($artwork);
|
||||
makeFeaturedArtworkSource($artwork, $localRoot);
|
||||
|
||||
$this->artisan('skinbase:featured-thumbnails:generate', [
|
||||
'--artwork' => [(string) $artwork->id],
|
||||
])->assertExitCode(0);
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
|
||||
foreach ($paths->variantNames() as $variant) {
|
||||
Storage::disk('s3')->assertExists($paths->objectPath($artwork, $variant));
|
||||
}
|
||||
});
|
||||
|
||||
test('featured thumbnail generation purges Cloudflare for generated featured variants', function () {
|
||||
Http::fake([
|
||||
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
|
||||
]);
|
||||
|
||||
Storage::fake('s3');
|
||||
|
||||
$localRoot = storage_path('framework/testing/featured-originals-command-purge');
|
||||
$backupRoot = storage_path('framework/testing/featured-originals-command-purge-backup');
|
||||
|
||||
File::deleteDirectory($localRoot);
|
||||
File::deleteDirectory($backupRoot);
|
||||
File::ensureDirectoryExists($localRoot);
|
||||
File::ensureDirectoryExists($backupRoot);
|
||||
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'uploads.local_originals_root' => $localRoot,
|
||||
'uploads.readonly_backup_originals_root' => $backupRoot,
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
'cdn.cloudflare.zone_id' => 'test-zone',
|
||||
'cdn.cloudflare.api_token' => 'test-token',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Featured Command Artwork Purge',
|
||||
'hash' => str_repeat('d', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
insertFeaturedArtworkRow($artwork);
|
||||
makeFeaturedArtworkSource($artwork, $localRoot);
|
||||
|
||||
$this->artisan('skinbase:featured-thumbnails:generate', [
|
||||
'--artwork' => [(string) $artwork->id],
|
||||
])->assertExitCode(0);
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
$expectedUrls = collect($paths->variantNames())
|
||||
->map(fn (string $variant): string => $paths->url($artwork, $variant))
|
||||
->all();
|
||||
|
||||
Http::assertSent(function ($request) use ($expectedUrls): bool {
|
||||
$data = $request->data();
|
||||
|
||||
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
|
||||
&& $request->method() === 'POST'
|
||||
&& ($data['files'] ?? null) === $expectedUrls;
|
||||
});
|
||||
});
|
||||
|
||||
test('featured thumbnail command can queue generation jobs', function () {
|
||||
Queue::fake();
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'hash' => str_repeat('c', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
insertFeaturedArtworkRow($artwork);
|
||||
|
||||
$this->artisan('skinbase:featured-thumbnails:generate', [
|
||||
'--artwork' => [(string) $artwork->id],
|
||||
'--queue' => true,
|
||||
])->assertExitCode(0);
|
||||
|
||||
Queue::assertPushed(GenerateFeaturedArtworkThumbnailsJob::class, 1);
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Models\User;
|
||||
use App\Services\Security\TurnstileVerifier;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
@@ -11,6 +11,7 @@ uses(RefreshDatabase::class);
|
||||
|
||||
it('rejects registration when honeypot field is filled', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$response = $this->from('/register')->post('/register', [
|
||||
'email' => 'bot1@example.com',
|
||||
@@ -24,6 +25,7 @@ it('rejects registration when honeypot field is filled', function () {
|
||||
|
||||
it('throttles excessive registration attempts by ip', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
config()->set('registration.ip_per_minute_limit', 2);
|
||||
config()->set('registration.ip_per_day_limit', 100);
|
||||
|
||||
@@ -45,6 +47,7 @@ it('throttles excessive registration attempts by ip', function () {
|
||||
|
||||
it('blocks disposable email domains during registration', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
config()->set('registration.disposable_domains_enabled', true);
|
||||
config()->set('disposable_email_domains.domains', ['tempmail.com']);
|
||||
|
||||
@@ -59,42 +62,56 @@ it('blocks disposable email domains during registration', function () {
|
||||
|
||||
it('requires turnstile after suspicious registration attempts', function () {
|
||||
Queue::fake();
|
||||
config()->set('registration.enable_turnstile', true);
|
||||
config()->set('registration.turnstile_suspicious_attempts', 1);
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
$mock = \Mockery::mock(TurnstileVerifier::class);
|
||||
$mock->shouldReceive('isEnabled')->andReturn(true);
|
||||
$mock->shouldReceive('verify')->once()->andReturn(false);
|
||||
$this->app->instance(TurnstileVerifier::class, $mock);
|
||||
|
||||
$response = $this->from('/register')->post('/register', [
|
||||
'email' => 'captcha-user@example.com',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/register');
|
||||
$response->assertSessionHasErrors('captcha');
|
||||
$response->assertSessionHasErrors('turnstile_token');
|
||||
$this->assertDatabaseMissing('users', ['email' => 'captcha-user@example.com']);
|
||||
});
|
||||
|
||||
it('shows turnstile when ip is in rate-limited state', function () {
|
||||
config()->set('registration.enable_turnstile', true);
|
||||
config()->set('registration.ip_per_minute_limit', 1);
|
||||
it('shows turnstile on the registration screen when enabled', function () {
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
RateLimiter::hit('register:ip:127.0.0.1', 60);
|
||||
|
||||
$this->get('/register')
|
||||
->assertOk()
|
||||
->assertSee('cf-turnstile', false);
|
||||
});
|
||||
|
||||
RateLimiter::clear('register:ip:127.0.0.1');
|
||||
it('rejects registration when turnstile verification fails', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
Http::fake([
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify' => Http::response([
|
||||
'success' => false,
|
||||
'error-codes' => ['invalid-input-response'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->from('/register')->post('/register', [
|
||||
'email' => 'captcha-fail@example.com',
|
||||
'turnstile_token' => 'bad-token',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/register');
|
||||
$response->assertSessionHasErrors('turnstile_token');
|
||||
$this->assertDatabaseMissing('users', ['email' => 'captcha-fail@example.com']);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('enforces verification email cooldown per address', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$first = $this->post('/register', [
|
||||
'email' => 'cooldown2@example.com',
|
||||
@@ -114,6 +131,7 @@ it('enforces verification email cooldown per address', function () {
|
||||
|
||||
it('rejects registration for existing completed emails', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
User::factory()->create([
|
||||
'email' => 'existing@example.com',
|
||||
@@ -133,30 +151,36 @@ it('rejects registration for existing completed emails', function () {
|
||||
|
||||
it('still allows registration when turnstile passes', function () {
|
||||
Queue::fake();
|
||||
config()->set('registration.enable_turnstile', true);
|
||||
config()->set('registration.turnstile_suspicious_attempts', 1);
|
||||
config()->set('services.turnstile.enabled', true);
|
||||
config()->set('services.turnstile.site_key', 'site-key');
|
||||
config()->set('services.turnstile.secret_key', 'secret-key');
|
||||
|
||||
$mock = \Mockery::mock(TurnstileVerifier::class);
|
||||
$mock->shouldReceive('isEnabled')->andReturn(true);
|
||||
$mock->shouldReceive('verify')->once()->andReturn(false);
|
||||
$mock->shouldReceive('verify')->once()->andReturn(true);
|
||||
$this->app->instance(TurnstileVerifier::class, $mock);
|
||||
|
||||
$first = $this->from('/register')->post('/register', [
|
||||
'email' => 'captcha-block@example.com',
|
||||
Http::fake([
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify' => Http::response([
|
||||
'success' => true,
|
||||
'hostname' => 'skinbase.org',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$first->assertRedirect('/register');
|
||||
$first->assertSessionHasErrors('captcha');
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'email' => 'captcha-pass@example.com',
|
||||
'cf-turnstile-response' => 'good-token',
|
||||
'turnstile_token' => 'good-token',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/setup/password');
|
||||
$this->assertDatabaseHas('users', ['email' => 'captcha-pass@example.com']);
|
||||
Queue::assertNothingPushed();
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('does not require turnstile when disabled', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'email' => 'turnstile-disabled@example.com',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/setup/password');
|
||||
$this->assertDatabaseHas('users', ['email' => 'turnstile-disabled@example.com']);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ test('registration screen can be rendered', function () {
|
||||
|
||||
test('new users can register', function () {
|
||||
Queue::fake();
|
||||
config()->set('services.turnstile.enabled', false);
|
||||
|
||||
$response = $this->post('/register', [
|
||||
'email' => 'test@example.com',
|
||||
|
||||
@@ -4,11 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\HomepageService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\HomepageService;
|
||||
use App\Support\ArtworkFeaturedImagePath;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -132,6 +134,29 @@ test('featured query excludes inactive and expired feature rows', function () {
|
||||
->and($featuredIds)->not->toContain($expiredArtwork->id);
|
||||
});
|
||||
|
||||
test('featured page ignores type filtering when the feature type column is absent', function () {
|
||||
$owner = User::factory()->create();
|
||||
$artwork = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Type Filter Fallback']);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 100,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$this->get(route('featured', ['type' => 3]))
|
||||
->assertOk()
|
||||
->assertSee('Type Filter Fallback', false);
|
||||
});
|
||||
|
||||
test('featured hero sorts by priority before featured_at', function () {
|
||||
$owner = User::factory()->create();
|
||||
$higherPriority = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Higher Priority']);
|
||||
@@ -276,6 +301,60 @@ test('homepage hero payload uses the forced hero artwork when one is set', funct
|
||||
->and($hero['title'])->toBe('Forced Homepage Hero');
|
||||
});
|
||||
|
||||
test('homepage renders featured hero picture and preload from dedicated featured thumbnails', function () {
|
||||
Cache::flush();
|
||||
Storage::fake('s3');
|
||||
config([
|
||||
'uploads.object_storage.disk' => 's3',
|
||||
'cdn.files_url' => 'https://files.skinbase.org',
|
||||
]);
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$artwork = makeFeaturedArtwork([
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Hero With Dedicated Featured Images',
|
||||
'hash' => str_repeat('a', 64),
|
||||
'file_ext' => 'png',
|
||||
'thumb_ext' => 'webp',
|
||||
]);
|
||||
|
||||
DB::table('artwork_features')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'featured_at' => now()->subHour(),
|
||||
'expires_at' => null,
|
||||
'priority' => 900,
|
||||
'label' => null,
|
||||
'note' => null,
|
||||
'is_active' => true,
|
||||
'force_hero' => true,
|
||||
'created_by' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$paths = app(ArtworkFeaturedImagePath::class);
|
||||
|
||||
foreach ($paths->variantNames() as $variant) {
|
||||
Storage::disk('s3')->put($paths->objectPath($artwork, $variant), 'featured-image');
|
||||
}
|
||||
|
||||
$desktopUrl = $paths->url($artwork, 'desktop');
|
||||
$desktopXlUrl = $paths->url($artwork, 'desktop_xl');
|
||||
$xsUrl = $paths->url($artwork, 'xs');
|
||||
$mobileUrl = $paths->url($artwork, 'mobile');
|
||||
|
||||
$this->get(route('index'))
|
||||
->assertOk()
|
||||
->assertSee($desktopUrl, false)
|
||||
->assertSee($desktopXlUrl, false)
|
||||
->assertSee($xsUrl, false)
|
||||
->assertSee($mobileUrl, false)
|
||||
->assertSee('rel="preload"', false)
|
||||
->assertSee('type="image/webp"', false)
|
||||
->assertSee('fetchpriority="high"', false);
|
||||
});
|
||||
|
||||
test('community favorites returns artworks ordered by recent medal score', function () {
|
||||
$owner = User::factory()->create();
|
||||
$leader = makeFeaturedArtwork(['user_id' => $owner->id, 'title' => 'Leader']);
|
||||
@@ -412,4 +491,4 @@ test('trending backfills with archive artworks when the recent ranking pool is s
|
||||
->and($resultIds[0])->toBe($recentLeader->id)
|
||||
->and($resultIds)->toContain($archiveA->id)
|
||||
->and($resultIds)->toContain($archiveB->id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,91 @@ it('renders published news across public discovery routes', function (): void {
|
||||
->assertSee('Skinbase Newsroom');
|
||||
});
|
||||
|
||||
it('renders news index breadcrumbs and item list schema', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'indexschemaauthor',
|
||||
'name' => 'Index Schema Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Announcements',
|
||||
'slug' => 'announcements-index-schema',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Index Schema Article',
|
||||
'slug' => 'index-schema-article',
|
||||
]);
|
||||
|
||||
$this->get(route('news.index'))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Home', 'News'])
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('ItemList', false)
|
||||
->assertSee(route('news.show', ['slug' => $article->slug]), false);
|
||||
});
|
||||
|
||||
it('shows only popular news topics in the sidebar and links to the full tags page', function (): void {
|
||||
config()->set('news.sidebar_tags_limit', 2);
|
||||
|
||||
$author = User::factory()->create([
|
||||
'username' => 'sidebarauthor',
|
||||
'name' => 'Sidebar Author',
|
||||
]);
|
||||
$category = newsCategory([
|
||||
'name' => 'Sidebar Category',
|
||||
'slug' => 'sidebar-category',
|
||||
]);
|
||||
|
||||
$tagAlpha = newsTag([
|
||||
'name' => 'Alpha Topic',
|
||||
'slug' => 'alpha-topic',
|
||||
]);
|
||||
$tagBeta = newsTag([
|
||||
'name' => 'Beta Topic',
|
||||
'slug' => 'beta-topic',
|
||||
]);
|
||||
$tagGamma = newsTag([
|
||||
'name' => 'Gamma Topic',
|
||||
'slug' => 'gamma-topic',
|
||||
]);
|
||||
|
||||
$articleOne = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Sidebar article one',
|
||||
'slug' => 'sidebar-article-one',
|
||||
'is_featured' => false,
|
||||
'is_pinned' => false,
|
||||
]);
|
||||
$articleOne->tags()->sync([$tagAlpha->id, $tagBeta->id]);
|
||||
|
||||
$articleTwo = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Sidebar article two',
|
||||
'slug' => 'sidebar-article-two',
|
||||
'is_featured' => false,
|
||||
'is_pinned' => false,
|
||||
'published_at' => now()->subMinutes(30),
|
||||
]);
|
||||
$articleTwo->tags()->sync([$tagAlpha->id]);
|
||||
|
||||
$articleThree = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Sidebar article three',
|
||||
'slug' => 'sidebar-article-three',
|
||||
'is_featured' => false,
|
||||
'is_pinned' => false,
|
||||
'published_at' => now()->subMinutes(15),
|
||||
]);
|
||||
$articleThree->tags()->sync([$tagGamma->id]);
|
||||
|
||||
$this->get(route('news.index'))
|
||||
->assertOk()
|
||||
->assertSee('Popular Topics')
|
||||
->assertSee('All Tags')
|
||||
->assertSee(route('tags.index'), false)
|
||||
->assertSee('#Alpha Topic')
|
||||
->assertSee('#Beta Topic')
|
||||
->assertDontSee('#Gamma Topic');
|
||||
});
|
||||
|
||||
it('renders a public news article when anonymous sessions are skipped', function (): void {
|
||||
Config::set('skinbase-sessions.enabled', true);
|
||||
Config::set('skinbase-sessions.debug_header', true);
|
||||
@@ -128,4 +213,135 @@ it('renders a public news article when anonymous sessions are skipped', function
|
||||
->assertOk()
|
||||
->assertHeader('X-Skinbase-Session', 'skipped')
|
||||
->assertSee('Guest Sessionless News Page');
|
||||
});
|
||||
|
||||
it('renders article breadcrumbs with home news category hierarchy', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'breadcrumbauthor',
|
||||
'name' => 'Breadcrumb Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Technology',
|
||||
'slug' => 'technology',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Breadcrumb Check Article',
|
||||
'slug' => 'breadcrumb-check-article',
|
||||
]);
|
||||
|
||||
$this->get(route('news.show', ['slug' => $article->slug]))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Home', 'News', 'Technology']);
|
||||
});
|
||||
|
||||
it('renders category breadcrumbs and item list schema', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'categoryschemaauthor',
|
||||
'name' => 'Category Schema Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Technology',
|
||||
'slug' => 'technology-schema',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Category Schema Article',
|
||||
'slug' => 'category-schema-article',
|
||||
]);
|
||||
|
||||
$this->get(route('news.category', ['slug' => $category->slug]))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Home', 'News', 'Technology'])
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('ItemList', false)
|
||||
->assertSee(route('news.show', ['slug' => $article->slug]), false);
|
||||
});
|
||||
|
||||
it('renders structured data for public news pages', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'schemaauthor',
|
||||
'name' => 'Schema Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Technology',
|
||||
'slug' => 'technology-structured-data',
|
||||
]);
|
||||
|
||||
$tag = newsTag([
|
||||
'name' => 'Game Art',
|
||||
'slug' => 'game-art-structured-data',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Structured Data Article',
|
||||
'slug' => 'structured-data-article',
|
||||
]);
|
||||
$article->tags()->sync([$tag->id]);
|
||||
|
||||
$this->get(route('news.index'))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false)
|
||||
->assertSee('ItemList', false);
|
||||
|
||||
$this->get(route('news.category', ['slug' => $category->slug]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false)
|
||||
->assertSee('ItemList', false);
|
||||
|
||||
$this->get(route('news.tag', ['slug' => $tag->slug]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
|
||||
$this->get(route('news.archive', ['year' => $article->published_at->year, 'month' => $article->published_at->month]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
|
||||
$this->get(route('news.author', ['username' => $author->username]))
|
||||
->assertOk()
|
||||
->assertSee('CollectionPage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
|
||||
$this->get(route('news.show', ['slug' => $article->slug]))
|
||||
->assertOk()
|
||||
->assertSee('NewsArticle', false)
|
||||
->assertSee('ImageObject', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
});
|
||||
|
||||
it('prioritizes the news hero image and skips the grid stylesheet on article pages', function (): void {
|
||||
$author = User::factory()->create([
|
||||
'username' => 'lcpauthor',
|
||||
'name' => 'LCP Author',
|
||||
]);
|
||||
|
||||
$category = newsCategory([
|
||||
'name' => 'Performance',
|
||||
'slug' => 'performance-news',
|
||||
]);
|
||||
|
||||
$article = publishedNewsArticle($author, $category, [
|
||||
'title' => 'Performance News Article',
|
||||
'slug' => 'performance-news-article',
|
||||
'cover_image' => 'news/covers/ab/cd/abcdef1234567890.webp',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('news.show', ['slug' => $article->slug]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('imagesizes="(max-width: 767px) calc(100vw - 3rem), (max-width: 1279px) calc(100vw - 5rem), 768px"', false)
|
||||
->assertSee(' 768w', false)
|
||||
->assertSee('href="' . e($article->cover_desktop_url ?? $article->cover_url) . '"', false)
|
||||
->assertDontSee('id="news-cover-preview"', false)
|
||||
->assertDontSee('resources/css/nova-grid.css', false);
|
||||
});
|
||||
@@ -5,9 +5,16 @@ declare(strict_types=1);
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use cPad\Plugins\News\Models\NewsCategory;
|
||||
use cPad\Plugins\News\Models\NewsTag;
|
||||
use App\Models\Artwork;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function studioNewsCategory(array $attributes = []): NewsCategory
|
||||
{
|
||||
@@ -298,4 +305,124 @@ it('soft deletes a newsroom article from studio', function (): void {
|
||||
$this->assertSoftDeleted('news_articles', [
|
||||
'id' => $article->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('uploads newsroom cover images with responsive variants and deletes them together', function (): void {
|
||||
Storage::fake('s3');
|
||||
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
config()->set('cdn.files_url', 'https://cdn.skinbase.test');
|
||||
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
]);
|
||||
|
||||
$uploadResponse = $this->actingAs($moderator)->postJson(route('api.studio.news.media.upload'), [
|
||||
'image' => UploadedFile::fake()->image('news-cover.jpg', 1600, 900),
|
||||
]);
|
||||
|
||||
$uploadResponse->assertOk();
|
||||
|
||||
$path = (string) $uploadResponse->json('path');
|
||||
$mobileUrl = (string) $uploadResponse->json('mobile_url');
|
||||
$desktopUrl = (string) $uploadResponse->json('desktop_url');
|
||||
$srcset = (string) $uploadResponse->json('srcset');
|
||||
|
||||
expect($path)->toMatch('#^news/covers/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]{64}\.webp$#');
|
||||
expect($mobileUrl)->toBe('https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-mobile.webp', $path));
|
||||
expect($desktopUrl)->toBe('https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-desktop.webp', $path));
|
||||
expect($srcset)->toContain($mobileUrl . ' 400w')
|
||||
->toContain($desktopUrl . ' 768w');
|
||||
|
||||
Storage::disk('s3')->assertExists($path);
|
||||
Storage::disk('s3')->assertExists(preg_replace('#\.webp$#', '-mobile.webp', $path));
|
||||
Storage::disk('s3')->assertExists(preg_replace('#\.webp$#', '-desktop.webp', $path));
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->deleteJson(route('api.studio.news.media.destroy'), ['path' => $path])
|
||||
->assertOk();
|
||||
|
||||
Storage::disk('s3')->assertMissing($path);
|
||||
Storage::disk('s3')->assertMissing(preg_replace('#\.webp$#', '-mobile.webp', $path));
|
||||
Storage::disk('s3')->assertMissing(preg_replace('#\.webp$#', '-desktop.webp', $path));
|
||||
});
|
||||
|
||||
it('backfills missing responsive variants for managed newsroom covers', function (): void {
|
||||
Storage::fake('s3');
|
||||
Http::fake([
|
||||
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
|
||||
]);
|
||||
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
config()->set('cdn.files_url', 'https://cdn.skinbase.test');
|
||||
config()->set('cdn.cloudflare.zone_id', 'test-zone');
|
||||
config()->set('cdn.cloudflare.api_token', 'test-token');
|
||||
|
||||
$author = User::factory()->create();
|
||||
$category = studioNewsCategory();
|
||||
$masterPath = 'news/covers/aa/bb/' . str_repeat('a', 64) . '.webp';
|
||||
|
||||
Storage::disk('s3')->put($masterPath, UploadedFile::fake()->image('source.jpg', 1600, 900)->get());
|
||||
|
||||
NewsArticle::query()->create([
|
||||
'title' => 'Backfill cover variants',
|
||||
'slug' => 'backfill-cover-variants',
|
||||
'excerpt' => 'Backfill test.',
|
||||
'content' => 'Backfill test body.',
|
||||
'author_id' => $author->id,
|
||||
'category_id' => $category->id,
|
||||
'cover_image' => $masterPath,
|
||||
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
||||
'status' => 'draft',
|
||||
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
||||
]);
|
||||
|
||||
$this->artisan('news:generate-cover-thumbnails')
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('generated=1');
|
||||
|
||||
Storage::disk('s3')->assertExists('news/covers/aa/bb/' . str_repeat('a', 64) . '-mobile.webp');
|
||||
Storage::disk('s3')->assertExists('news/covers/aa/bb/' . str_repeat('a', 64) . '-desktop.webp');
|
||||
|
||||
Http::assertNothingSent();
|
||||
|
||||
$this->artisan('news:generate-cover-thumbnails', ['--force' => true])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('generated=1');
|
||||
|
||||
Http::assertSent(function ($request) use ($masterPath): bool {
|
||||
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
|
||||
&& $request->hasHeader('Authorization', 'Bearer test-token')
|
||||
&& $request['files'] === [
|
||||
'https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-mobile.webp', $masterPath),
|
||||
'https://cdn.skinbase.test/' . preg_replace('#\.webp$#', '-desktop.webp', $masterPath),
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
it('searches news artwork entities without relying on a top-level views column', function (): void {
|
||||
$moderator = User::factory()->create([
|
||||
'role' => 'moderator',
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Entity Search Artwork',
|
||||
'slug' => 'entity-search-artwork',
|
||||
'artwork_status' => 'published',
|
||||
'is_public' => true,
|
||||
'visibility' => Artwork::VISIBILITY_PUBLIC,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$this->actingAs($moderator)
|
||||
->getJson(route('studio.news.entity-search', [
|
||||
'type' => 'artwork',
|
||||
'q' => 'Entity Search',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonFragment([
|
||||
'id' => $artwork->id,
|
||||
'title' => 'Entity Search Artwork',
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user