428 lines
16 KiB
PHP
428 lines
16 KiB
PHP
<?php
|
|
|
|
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
|
|
{
|
|
return NewsCategory::query()->create(array_merge([
|
|
'name' => 'Studio News',
|
|
'slug' => 'studio-news',
|
|
'description' => 'Studio News category',
|
|
'position' => 0,
|
|
'is_active' => true,
|
|
], $attributes));
|
|
}
|
|
|
|
function studioNewsTag(array $attributes = []): NewsTag
|
|
{
|
|
return NewsTag::query()->create(array_merge([
|
|
'name' => 'Studio',
|
|
'slug' => 'studio',
|
|
], $attributes));
|
|
}
|
|
|
|
it('forbids newsroom studio pages for non moderators', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$this->actingAs($user)
|
|
->get(route('studio.news.index'))
|
|
->assertForbidden();
|
|
});
|
|
|
|
it('renders newsroom studio pages for moderators', function (): void {
|
|
$moderator = User::factory()->create([
|
|
'role' => 'moderator',
|
|
'username' => 'modnews',
|
|
'name' => 'Moderator News',
|
|
]);
|
|
$author = User::factory()->create([
|
|
'username' => 'writernews',
|
|
'name' => 'Writer News',
|
|
]);
|
|
$category = studioNewsCategory();
|
|
$tag = studioNewsTag();
|
|
$article = NewsArticle::query()->create([
|
|
'title' => 'Moderated newsroom article',
|
|
'slug' => 'moderated-newsroom-article',
|
|
'excerpt' => 'Studio-managed newsroom article.',
|
|
'content' => 'Studio body',
|
|
'author_id' => $author->id,
|
|
'category_id' => $category->id,
|
|
'type' => NewsArticle::TYPE_EDITORIAL,
|
|
'status' => 'published',
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_PUBLISHED,
|
|
'published_at' => Carbon::parse('2026-04-05 09:30:00'),
|
|
]);
|
|
$article->tags()->sync([$tag->id]);
|
|
|
|
$this->actingAs($moderator)
|
|
->get(route('studio.news.index'))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioNewsIndex')
|
|
->where('title', 'Newsroom')
|
|
->where('listing.items.0.title', 'Moderated newsroom article')
|
|
->where('createUrl', route('studio.news.create')));
|
|
|
|
$this->actingAs($moderator)
|
|
->get(route('studio.news.create'))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioNewsEditor')
|
|
->where('title', 'Create article')
|
|
->has('typeOptions')
|
|
->has('statusOptions')
|
|
->has('categoryOptions')
|
|
->has('tagOptions')
|
|
->where('defaultAuthor.id', $moderator->id));
|
|
|
|
$this->actingAs($moderator)
|
|
->get(route('studio.news.categories'))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioNewsTaxonomies')
|
|
->where('activeTab', 'categories')
|
|
->has('categories.0')
|
|
->has('tags.0'));
|
|
|
|
$this->actingAs($moderator)
|
|
->get(route('studio.news.preview', ['article' => $article->id]))
|
|
->assertOk()
|
|
->assertSee('Preview mode')
|
|
->assertSee('Moderated newsroom article');
|
|
});
|
|
|
|
it('filters newsroom listing by status type and category', function (): void {
|
|
$moderator = User::factory()->create([
|
|
'role' => 'moderator',
|
|
]);
|
|
$category = studioNewsCategory([
|
|
'name' => 'Filtered Category',
|
|
'slug' => 'filtered-category',
|
|
]);
|
|
$otherCategory = studioNewsCategory([
|
|
'name' => 'Other Category',
|
|
'slug' => 'other-category',
|
|
]);
|
|
$author = User::factory()->create();
|
|
|
|
NewsArticle::query()->create([
|
|
'title' => 'Keep Me',
|
|
'slug' => 'keep-me',
|
|
'excerpt' => 'Should survive filtering.',
|
|
'content' => 'Content',
|
|
'author_id' => $author->id,
|
|
'category_id' => $category->id,
|
|
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
|
'status' => 'draft',
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
]);
|
|
|
|
NewsArticle::query()->create([
|
|
'title' => 'Drop Me By Type',
|
|
'slug' => 'drop-me-type',
|
|
'excerpt' => 'Wrong type.',
|
|
'content' => 'Content',
|
|
'author_id' => $author->id,
|
|
'category_id' => $category->id,
|
|
'type' => NewsArticle::TYPE_EDITORIAL,
|
|
'status' => 'draft',
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
]);
|
|
|
|
NewsArticle::query()->create([
|
|
'title' => 'Drop Me By Category',
|
|
'slug' => 'drop-me-category',
|
|
'excerpt' => 'Wrong category.',
|
|
'content' => 'Content',
|
|
'author_id' => $author->id,
|
|
'category_id' => $otherCategory->id,
|
|
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
|
'status' => 'draft',
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
]);
|
|
|
|
$this->actingAs($moderator)
|
|
->get(route('studio.news.index', [
|
|
'status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
|
'category_id' => $category->id,
|
|
]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioNewsIndex')
|
|
->where('listing.filters.status', NewsArticle::EDITORIAL_STATUS_DRAFT)
|
|
->where('listing.filters.type', NewsArticle::TYPE_ANNOUNCEMENT)
|
|
->where('listing.filters.category_id', $category->id)
|
|
->has('listing.items', 1)
|
|
->where('listing.items.0.title', 'Keep Me'));
|
|
});
|
|
|
|
it('stores a newsroom draft with taxonomy links', function (): void {
|
|
$moderator = User::factory()->create([
|
|
'role' => 'moderator',
|
|
'username' => 'editornews',
|
|
'name' => 'Editor News',
|
|
]);
|
|
$author = User::factory()->create();
|
|
$category = studioNewsCategory([
|
|
'name' => 'Launches',
|
|
'slug' => 'launches',
|
|
]);
|
|
$tag = studioNewsTag([
|
|
'name' => 'Update',
|
|
'slug' => 'update',
|
|
]);
|
|
|
|
$response = $this->actingAs($moderator)->post(route('studio.news.store'), [
|
|
'title' => 'Stored newsroom draft',
|
|
'slug' => 'stored-newsroom-draft',
|
|
'excerpt' => 'Stored through the Studio newsroom form.',
|
|
'content' => 'This article was created through the new Studio News flow.',
|
|
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
|
'category_id' => $category->id,
|
|
'author_id' => $author->id,
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
'published_at' => null,
|
|
'tag_ids' => [$tag->id],
|
|
'new_tag_names' => ['Studio Exclusive'],
|
|
'is_featured' => true,
|
|
'is_pinned' => false,
|
|
'meta_title' => 'Stored newsroom draft meta',
|
|
'meta_description' => 'Stored newsroom draft description',
|
|
]);
|
|
|
|
$article = NewsArticle::query()->where('slug', 'stored-newsroom-draft')->firstOrFail();
|
|
|
|
$response->assertRedirect(route('studio.news.edit', ['article' => $article->id]));
|
|
|
|
$this->assertDatabaseHas('news_articles', [
|
|
'id' => $article->id,
|
|
'title' => 'Stored newsroom draft',
|
|
'slug' => 'stored-newsroom-draft',
|
|
'author_id' => $author->id,
|
|
'category_id' => $category->id,
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
'status' => 'draft',
|
|
]);
|
|
|
|
expect($article->tags()->pluck('news_tags.name')->all())
|
|
->toContain('Update')
|
|
->toContain('Studio Exclusive');
|
|
});
|
|
|
|
it('updates an existing newsroom article', function (): void {
|
|
$moderator = User::factory()->create([
|
|
'role' => 'moderator',
|
|
]);
|
|
$author = User::factory()->create();
|
|
$category = studioNewsCategory([
|
|
'name' => 'Editorial',
|
|
'slug' => 'editorial',
|
|
]);
|
|
$tag = studioNewsTag([
|
|
'name' => 'Feature',
|
|
'slug' => 'feature',
|
|
]);
|
|
|
|
$article = NewsArticle::query()->create([
|
|
'title' => 'Original newsroom article',
|
|
'slug' => 'original-newsroom-article',
|
|
'excerpt' => 'Original excerpt.',
|
|
'content' => 'Original content.',
|
|
'author_id' => $author->id,
|
|
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
|
|
'status' => 'draft',
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
]);
|
|
|
|
$this->actingAs($moderator)
|
|
->patch(route('studio.news.update', ['article' => $article->id]), [
|
|
'title' => 'Updated newsroom article',
|
|
'slug' => 'updated-newsroom-article',
|
|
'excerpt' => 'Updated excerpt.',
|
|
'content' => '<p>Updated content.</p>',
|
|
'type' => NewsArticle::TYPE_EDITORIAL,
|
|
'category_id' => $category->id,
|
|
'author_id' => $author->id,
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_IN_REVIEW,
|
|
'tag_ids' => [$tag->id],
|
|
'new_tag_names' => ['Deep Dive'],
|
|
'is_featured' => true,
|
|
'is_pinned' => true,
|
|
])
|
|
->assertSessionHasNoErrors()
|
|
->assertRedirect();
|
|
|
|
$article->refresh();
|
|
|
|
expect($article->title)->toBe('Updated newsroom article')
|
|
->and($article->slug)->toBe('updated-newsroom-article')
|
|
->and($article->type)->toBe(NewsArticle::TYPE_EDITORIAL)
|
|
->and($article->editorial_status)->toBe(NewsArticle::EDITORIAL_STATUS_IN_REVIEW)
|
|
->and((int) $article->category_id)->toBe($category->id)
|
|
->and((bool) $article->is_featured)->toBeTrue()
|
|
->and((bool) $article->is_pinned)->toBeTrue()
|
|
->and($article->tags()->pluck('news_tags.name')->all())->toContain('Feature')
|
|
->and($article->tags()->pluck('news_tags.name')->all())->toContain('Deep Dive');
|
|
});
|
|
|
|
it('soft deletes a newsroom article from studio', function (): void {
|
|
$moderator = User::factory()->create([
|
|
'role' => 'moderator',
|
|
]);
|
|
$author = User::factory()->create();
|
|
|
|
$article = NewsArticle::query()->create([
|
|
'title' => 'Delete me softly',
|
|
'slug' => 'delete-me-softly',
|
|
'excerpt' => 'Soft delete test article.',
|
|
'content' => 'Studio delete content.',
|
|
'author_id' => $author->id,
|
|
'type' => NewsArticle::TYPE_EDITORIAL,
|
|
'status' => 'draft',
|
|
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
|
|
]);
|
|
|
|
$this->actingAs($moderator)
|
|
->delete(route('studio.news.destroy', ['article' => $article->id]))
|
|
->assertRedirect(route('studio.news.index'));
|
|
|
|
$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',
|
|
]);
|
|
}); |