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' => '

Updated content.

', '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', ]); });