create(array_merge([ 'slug' => 'category-' . Str::lower(Str::random(8)), 'name' => 'Category ' . Str::upper(Str::random(4)), 'description' => 'Category description', 'active' => true, 'order_num' => 0, ], $attributes)); } function novaCardTemplate(array $attributes = []): NovaCardTemplate { return NovaCardTemplate::query()->create(array_merge([ 'slug' => 'template-' . Str::lower(Str::random(8)), 'name' => 'Template ' . Str::upper(Str::random(4)), 'description' => 'Template description', 'config_json' => [ 'font_preset' => 'modern-sans', 'gradient_preset' => 'midnight-nova', 'layout' => 'quote_heavy', 'text_align' => 'center', 'text_color' => '#ffffff', 'overlay_style' => 'dark-soft', ], 'supported_formats' => ['square', 'portrait'], 'active' => true, 'official' => true, 'order_num' => 0, ], $attributes)); } function novaCardTag(array $attributes = []): NovaCardTag { return NovaCardTag::query()->create(array_merge([ 'slug' => 'tag-' . Str::lower(Str::random(8)), 'name' => 'Tag ' . Str::upper(Str::random(4)), ], $attributes)); } function publishedNovaCard(User $user, array $attributes = []): NovaCard { $category = $attributes['category'] ?? novaCardCategory(); $template = $attributes['template'] ?? novaCardTemplate(); $card = NovaCard::query()->create(array_merge([ 'user_id' => $user->id, 'category_id' => $category->id, 'template_id' => $template->id, 'title' => 'Skybound Thought', 'slug' => 'skybound-thought', 'quote_text' => 'A bright sentence for public display.', 'quote_author' => 'Nova Author', 'quote_source' => 'Test Source', 'description' => 'A public card used in tests.', 'format' => NovaCard::FORMAT_SQUARE, 'project_json' => [ 'content' => [ 'title' => 'Skybound Thought', 'quote_text' => 'A bright sentence for public display.', 'quote_author' => 'Nova Author', 'quote_source' => 'Test Source', ], 'layout' => [ 'layout' => 'quote_heavy', 'position' => 'center', 'alignment' => 'center', 'padding' => 'comfortable', 'max_width' => 'balanced', ], 'typography' => [ 'font_preset' => 'modern-sans', 'text_color' => '#ffffff', 'accent_color' => '#e0f2fe', 'quote_size' => 72, 'author_size' => 28, 'letter_spacing' => 0, 'line_height' => 1.2, 'shadow_preset' => 'soft', ], 'background' => [ 'type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#0f172a', '#1d4ed8'], 'overlay_style' => 'dark-soft', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 50, ], 'decorations' => [], ], 'render_version' => 1, 'background_type' => 'gradient', 'visibility' => NovaCard::VISIBILITY_PUBLIC, 'status' => NovaCard::STATUS_PUBLISHED, 'moderation_status' => NovaCard::MOD_APPROVED, 'featured' => false, 'allow_download' => true, 'views_count' => 12, 'shares_count' => 3, 'downloads_count' => 1, 'published_at' => now()->subHour(), ], Arr::except($attributes, ['category', 'template', 'tags']))); foreach (($attributes['tags'] ?? []) as $tag) { $card->tags()->attach($tag->id); } return $card->fresh(['user.profile', 'category', 'template', 'tags']); } it('renders the public cards index with featured content', function (): void { $creator = User::factory()->create(['username' => 'novacreator']); $featured = publishedNovaCard($creator, ['featured' => true, 'title' => 'Featured Nova']); $latest = publishedNovaCard($creator, ['slug' => 'latest-nova', 'title' => 'Latest Nova']); $response = $this->get(route('cards.index')); $response->assertOk(); expect($response->getContent()) ->toContain('Nova Cards') ->toContain('Featured Nova') ->toContain('Latest Nova') ->toContain(route('cards.show', ['slug' => $featured->slug, 'id' => $featured->id])) ->toContain('application/ld+json') ->toContain('CollectionPage') ->toContain('index,follow'); }); it('renders category, tag, style, palette, and creator pages with their matching card', function (): void { $creator = User::factory()->create(['username' => 'tagcreator']); $category = novaCardCategory(['slug' => 'mindset', 'name' => 'Mindset']); $tag = novaCardTag(['slug' => 'clarity', 'name' => 'Clarity']); $moodTag = novaCardTag(['slug' => 'calm', 'name' => 'Calm']); $template = novaCardTemplate(['slug' => 'editorial-starter', 'name' => 'Editorial Starter']); $card = publishedNovaCard($creator, [ 'category' => $category, 'template' => $template, 'slug' => 'clarity-card', 'title' => 'Clarity Card', 'featured' => true, 'featured_score' => 95.0, 'style_family' => 'editorial', 'palette_family' => 'cool-tones', 'editor_mode_last_used' => 'full', 'views_count' => 120, 'likes_count' => 24, 'saves_count' => 18, 'remixes_count' => 5, 'tags' => [$tag, $moodTag], ]); $second = publishedNovaCard($creator, [ 'category' => $category, 'template' => $template, 'slug' => 'clarity-card-two', 'title' => 'Clarity Card Two', 'style_family' => 'editorial', 'palette_family' => 'cool-tones', 'editor_mode_last_used' => 'full', 'views_count' => 80, 'likes_count' => 14, 'saves_count' => 7, 'remixes_count' => 2, 'tags' => [$tag, $moodTag], ]); $remix = publishedNovaCard($creator, [ 'category' => $category, 'slug' => 'clarity-card-remix', 'title' => 'Clarity Card Remix', 'template' => $template, 'original_card_id' => $card->id, 'root_card_id' => $card->id, 'editor_mode_last_used' => 'quick', 'views_count' => 50, 'likes_count' => 9, 'saves_count' => 4, 'remixes_count' => 1, 'tags' => [$tag, $moodTag], ]); NovaCardCreatorPreset::query()->create([ 'user_id' => $creator->id, 'name' => 'Clarity Style', 'preset_type' => NovaCardCreatorPreset::TYPE_STYLE, 'config_json' => ['typography' => ['font_preset' => 'modern-sans']], 'is_default' => true, ]); NovaCardCreatorPreset::query()->create([ 'user_id' => $creator->id, 'name' => 'Editorial Starter', 'preset_type' => NovaCardCreatorPreset::TYPE_STARTER, 'config_json' => ['template' => ['slug' => 'editorial-starter']], 'is_default' => false, ]); $collection = NovaCardCollection::query()->create([ 'user_id' => $creator->id, 'slug' => 'clarity-picks', 'name' => 'Clarity Picks', 'description' => 'A featured public collection from this creator.', 'visibility' => NovaCardCollection::VISIBILITY_PUBLIC, 'official' => false, 'featured' => true, 'cards_count' => 2, ]); NovaCardCollectionItem::query()->create([ 'collection_id' => $collection->id, 'card_id' => $card->id, 'sort_order' => 1, ]); NovaCardCollectionItem::query()->create([ 'collection_id' => $collection->id, 'card_id' => $second->id, 'sort_order' => 2, ]); $challenge = NovaCardChallenge::query()->create([ 'slug' => 'clarity-sprint', 'title' => 'Clarity Sprint', 'description' => 'A creator challenge history entry.', 'status' => NovaCardChallenge::STATUS_COMPLETED, 'official' => true, 'featured' => true, 'entries_count' => 2, ]); NovaCardChallengeEntry::query()->create([ 'challenge_id' => $challenge->id, 'card_id' => $card->id, 'user_id' => $creator->id, 'status' => NovaCardChallengeEntry::STATUS_WINNER, ]); $this->get(route('cards.category', ['categorySlug' => $category->slug])) ->assertOk(); expect($this->get(route('cards.category', ['categorySlug' => $category->slug]))->getContent()) ->toContain('Mindset') ->toContain('Clarity Card'); expect($this->get(route('cards.tag', ['tagSlug' => $tag->slug]))->getContent()) ->toContain('#Clarity') ->toContain('Clarity Card'); expect($this->get(route('cards.style', ['styleSlug' => 'editorial']))->getContent()) ->toContain('Editorial') ->toContain('Clarity Card') ->toContain('Style families'); expect($this->get(route('cards.palette', ['paletteSlug' => 'cool-tones']))->getContent()) ->toContain('Cool Tones') ->toContain('Clarity Card') ->toContain('Palette families'); expect($this->get(route('cards.creator', ['username' => $creator->username]))->getContent()) ->toContain('@' . $creator->username) ->toContain('Clarity Card') ->toContain('Creator profile') ->toContain('Featured works') ->toContain('Featured collections') ->toContain('Clarity Picks') ->toContain('Signature themes') ->toContain('Cool Tones') ->toContain('Soft Morning') ->toContain('Most remixed works') ->toContain('Most liked works') ->toContain('Remix branches') ->toContain('Community branches') ->toContain('Published remixes') ->toContain('Published remix') ->toContain('Community branch') ->toContain('Clarity Card Remix') ->toContain('Source:') ->toContain('View lineage') ->toContain('Remix graph') ->toContain('Peak branch card') ->toContain('Creator identity') ->toContain('Preference signals') ->toContain('Editorial Starter') ->toContain('Preferred editor mode') ->toContain('Full') ->toContain('Saved presets') ->toContain('Style') ->toContain('Starter') ->toContain('Recent timeline') ->toContain('Featured release') ->toContain('Audience favorite') ->toContain('Remix traction') ->toContain('Challenge track record') ->toContain('Clarity Sprint') ->toContain('Winner entry') ->toContain('Creator highlights') ->toContain('All published works') ->toContain('Editorial') ->toContain('#Clarity') ->toContain('Mindset') ->toContain('200') ->toContain('Clarity Card Two'); expect($this->get(route('cards.creator.portfolio', ['username' => $creator->username]))->getContent()) ->toContain('@' . $creator->username) ->toContain('Portfolio') ->toContain('Portfolio works') ->toContain('Profile overview') ->toContain('Portfolio page') ->toContain('Most liked works') ->toContain('Remix branches') ->toContain('Recent timeline') ->toContain('Clarity Card Remix'); }); it('renders the public card detail page and increments views', function (): void { Event::fake([NovaCardViewed::class]); $viewer = User::factory()->create(['username' => 'reportviewer']); $creator = User::factory()->create(['username' => 'detailcreator']); $category = novaCardCategory(['slug' => 'quotes', 'name' => 'Quotes']); $tag = novaCardTag(['slug' => 'focus', 'name' => 'Focus']); $card = publishedNovaCard($creator, [ 'category' => $category, 'slug' => 'detail-card', 'title' => 'Detail Card', 'quote_text' => 'Precision matters when pages are crawlable.', 'views_count' => 7, 'tags' => [$tag], ]); $response = $this->actingAs($viewer)->get(route('cards.show', ['slug' => $card->slug, 'id' => $card->id])); $response->assertOk(); expect($response->getContent()) ->toContain('Detail Card') ->toContain('Precision matters when pages are crawlable.') ->toContain('#Focus') ->toContain('CreativeWork') ->toContain('Copy link') ->toContain('data-card-report'); expect($card->fresh()->views_count)->toBe(8); Event::assertDispatched(NovaCardViewed::class); }); it('tracks share and download engagement for a public card', function (): void { Event::fake([NovaCardShared::class, NovaCardDownloaded::class]); $creator = User::factory()->create(['username' => 'engagementcreator']); $card = publishedNovaCard($creator, [ 'slug' => 'engagement-card', 'title' => 'Engagement Card', 'preview_path' => 'cards/previews/example.webp', ]); $this->postJson(route('api.cards.share', ['id' => $card->id])) ->assertOk() ->assertJsonPath('shares_count', 4); $this->postJson(route('api.cards.download', ['id' => $card->id])) ->assertOk() ->assertJsonPath('downloads_count', 2) ->assertJsonPath('download_url', $card->fresh()->previewUrl()); Event::assertDispatched(NovaCardShared::class); Event::assertDispatched(NovaCardDownloaded::class); }); it('redirects creator and show routes to canonical casing and slug', function (): void { $creator = User::factory()->create(['username' => 'CanonicalUser']); $card = publishedNovaCard($creator, ['slug' => 'canonical-card', 'title' => 'Canonical Card']); $this->get('/cards/creator/CANONICALUSER') ->assertRedirect(route('cards.creator', ['username' => 'canonicaluser'])); $this->get('/cards/creator/CANONICALUSER/portfolio') ->assertRedirect(route('cards.creator.portfolio', ['username' => 'canonicaluser'])); $this->get(route('cards.show', ['slug' => 'wrong-slug', 'id' => $card->id])) ->assertRedirect(route('cards.show', ['slug' => $card->slug, 'id' => $card->id])); }); it('renders a public collection detail page with curated cards', function (): void { $owner = User::factory()->create(['username' => 'collectionowner']); $first = publishedNovaCard($owner, ['slug' => 'collection-card-one', 'title' => 'Collection Card One']); $second = publishedNovaCard($owner, ['slug' => 'collection-card-two', 'title' => 'Collection Card Two']); $collection = NovaCardCollection::query()->create([ 'user_id' => $owner->id, 'slug' => 'launch-picks', 'name' => 'Launch Picks', 'description' => 'Curated launch cards.', 'visibility' => NovaCardCollection::VISIBILITY_PUBLIC, 'official' => true, 'featured' => true, 'cards_count' => 2, ]); NovaCardCollectionItem::query()->create(['collection_id' => $collection->id, 'card_id' => $first->id, 'sort_order' => 1, 'note' => 'Anchor card']); NovaCardCollectionItem::query()->create(['collection_id' => $collection->id, 'card_id' => $second->id, 'sort_order' => 2]); $response = $this->get(route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id])); $response->assertOk(); expect($response->getContent()) ->toContain('Launch Picks') ->toContain('Collection Card One') ->toContain('Collection Card Two') ->toContain('Anchor card') ->toContain('CollectionPage'); }); it('hides hidden challenge entries from the public card page', function (): void { $creator = User::factory()->create(['username' => 'challengeowner']); $card = publishedNovaCard($creator, ['slug' => 'challenge-visibility-card', 'title' => 'Challenge Visibility Card']); $challenge = NovaCardChallenge::query()->create([ 'slug' => 'hidden-entry-check', 'title' => 'Hidden Entry Check', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, ]); NovaCardChallengeEntry::query()->create([ 'challenge_id' => $challenge->id, 'card_id' => $card->id, 'user_id' => $creator->id, 'status' => NovaCardChallengeEntry::STATUS_HIDDEN, ]); $response = $this->get(route('cards.show', ['slug' => $card->slug, 'id' => $card->id])); $response->assertOk(); expect($response->getContent())->not->toContain('Hidden Entry Check'); }); it('renders a lineage page for remixed cards', function (): void { $creator = User::factory()->create(['username' => 'lineagecreator']); $root = publishedNovaCard($creator, ['slug' => 'lineage-root', 'title' => 'Lineage Root']); $remix = publishedNovaCard($creator, [ 'slug' => 'lineage-remix', 'title' => 'Lineage Remix', 'original_card_id' => $root->id, 'root_card_id' => $root->id, ]); $response = $this->get(route('cards.lineage', ['slug' => $remix->slug, 'id' => $remix->id])); $response->assertOk(); expect($response->getContent()) ->toContain('Lineage Remix') ->toContain('Lineage Root') ->toContain('Cards in this remix branch'); }); it('renders a best remixes page ranked by remix traction', function (): void { $creator = User::factory()->create(['username' => 'remixhighlightcreator']); $root = publishedNovaCard($creator, ['slug' => 'highlight-root', 'title' => 'Highlight Root']); $best = publishedNovaCard($creator, [ 'slug' => 'best-remix-card', 'title' => 'Best Remix Card', 'original_card_id' => $root->id, 'root_card_id' => $root->id, 'remixes_count' => 12, 'saves_count' => 30, 'likes_count' => 20, ]); $other = publishedNovaCard($creator, [ 'slug' => 'other-remix-card', 'title' => 'Other Remix Card', 'original_card_id' => $root->id, 'root_card_id' => $root->id, 'remixes_count' => 3, 'saves_count' => 4, 'likes_count' => 2, ]); $response = $this->get(route('cards.remix-highlights')); $response->assertOk(); expect($response->getContent()) ->toContain('Best remixes') ->toContain('Best Remix Card') ->toContain('Other Remix Card') ->toContain('Remix discovery') ->toContain(route('cards.show', ['slug' => $best->slug, 'id' => $best->id])) ->toContain('View lineage'); }); it('renders mood, editorial, and seasonal discovery pages', function (): void { $creator = User::factory()->create(['username' => 'discoverycreator', 'nova_featured_creator' => true]); $calm = novaCardTag(['slug' => 'calm', 'name' => 'Calm']); $winter = novaCardTag(['slug' => 'winter', 'name' => 'Winter']); $editorialCard = publishedNovaCard($creator, [ 'slug' => 'editorial-spotlight-card', 'title' => 'Editorial Spotlight Card', 'featured' => true, 'featured_score' => 88.5, ]); $moodCard = publishedNovaCard($creator, [ 'slug' => 'calm-mood-card', 'title' => 'Calm Mood Card', 'tags' => [$calm], ]); $seasonalCard = publishedNovaCard($creator, [ 'slug' => 'winter-card', 'title' => 'Winter Card', 'tags' => [$winter], ]); $collection = NovaCardCollection::query()->create([ 'user_id' => $creator->id, 'slug' => 'editorial-picks-collection', 'name' => 'Editorial Picks Collection', 'description' => 'A featured collection for editorial discovery.', 'visibility' => NovaCardCollection::VISIBILITY_PUBLIC, 'official' => true, 'featured' => true, 'cards_count' => 1, ]); NovaCardCollectionItem::query()->create([ 'collection_id' => $collection->id, 'card_id' => $editorialCard->id, 'sort_order' => 1, ]); $challenge = NovaCardChallenge::query()->create([ 'slug' => 'editorial-highlight-challenge', 'title' => 'Editorial Highlight Challenge', 'description' => 'A featured challenge for discovery.', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, 'featured' => true, 'entries_count' => 3, ]); expect($this->get(route('cards.mood', ['moodSlug' => 'soft-morning']))->getContent()) ->toContain('Soft Morning') ->toContain('Calm Mood Card') ->toContain('Mood families'); $editorialResponse = $this->get(route('cards.editorial')); $editorialResponse->assertOk()->assertViewHas('featuredCreators', function (array $creators): bool { return count($creators) === 1 && ($creators[0]['username'] ?? null) === 'discoverycreator' && ($creators[0]['featured_cards_count'] ?? null) === 1; }); expect($editorialResponse->getContent()) ->toContain('Editorial picks') ->toContain('Editorial Spotlight Card') ->toContain('Featured creators') ->toContain('Editorial Picks Collection') ->toContain('Editorial Highlight Challenge'); expect($this->get(route('cards.seasonal'))->getContent()) ->toContain('Seasonal cards') ->toContain('Winter Card') ->toContain('Seasonal hubs'); }); it('allows authenticated viewers to comment on public cards and delete their own comment', function (): void { $creator = User::factory()->create(['username' => 'commentcardcreator']); $viewer = User::factory()->create(['username' => 'commentcardviewer']); $card = publishedNovaCard($creator, ['slug' => 'commentable-card', 'title' => 'Commentable Card']); $this->actingAs($viewer) ->post(route('cards.comments.store', ['card' => $card->id]), [ 'body' => 'This layout has a strong editorial feel.', ]) ->assertRedirect(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]) . '#comments'); $comment = NovaCardComment::query()->latest('id')->first(); expect($comment)->not->toBeNull() ->and($comment->card_id)->toBe($card->id) ->and($comment->body)->toBe('This layout has a strong editorial feel.'); $show = $this->actingAs($viewer) ->get(route('cards.show', ['slug' => $card->slug, 'id' => $card->id])) ->assertOk(); expect($show->getContent()) ->toContain('Comments') ->toContain('This layout has a strong editorial feel.'); $this->actingAs($viewer) ->delete(route('cards.comments.destroy', ['card' => $card->id, 'comment' => $comment->id])) ->assertRedirect(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]) . '#comments'); expect($comment->fresh()->deleted_at)->not->toBeNull(); });