create(array_merge([ 'slug' => 'v2-category-' . Str::lower(Str::random(6)), 'name' => 'V2 Category', 'description' => 'Nova Cards v2 category', 'active' => true, 'order_num' => 0, ], $attributes)); } function v2Template(array $attributes = []): NovaCardTemplate { return NovaCardTemplate::query()->create(array_merge([ 'slug' => 'v2-template-' . Str::lower(Str::random(6)), 'name' => 'V2 Template', 'description' => 'Nova Cards v2 template', '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 v2PublishedCard(User $user, array $attributes = []): NovaCard { $category = $attributes['category'] ?? v2Category(); $template = $attributes['template'] ?? v2Template(); return NovaCard::query()->create(array_merge([ 'user_id' => $user->id, 'category_id' => $category->id, 'template_id' => $template->id, 'title' => 'V2 Public Card', 'slug' => 'v2-public-card-' . Str::lower(Str::random(5)), 'quote_text' => 'A card built for Nova Cards v2 tests.', 'quote_author' => 'Nova V2', 'quote_source' => 'Feature suite', 'description' => 'Published Nova Cards v2 test card.', 'format' => NovaCard::FORMAT_SQUARE, 'project_json' => [ 'schema_version' => 2, 'content' => [ 'title' => 'V2 Public Card', 'quote_text' => 'A card built for Nova Cards v2 tests.', 'quote_author' => 'Nova V2', 'quote_source' => 'Feature suite', ], 'text_blocks' => [ ['key' => 'title', 'type' => 'title', 'text' => 'V2 Public Card', 'enabled' => true], ['key' => 'quote', 'type' => 'quote', 'text' => 'A card built for Nova Cards v2 tests.', 'enabled' => true], ['key' => 'author', 'type' => 'author', 'text' => 'Nova V2', 'enabled' => true], ], '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' => [], 'assets' => ['pack_ids' => [], 'template_pack_ids' => [], 'items' => []], ], 'schema_version' => 2, 'render_version' => 2, 'background_type' => 'gradient', 'visibility' => NovaCard::VISIBILITY_PUBLIC, 'status' => NovaCard::STATUS_PUBLISHED, 'moderation_status' => NovaCard::MOD_APPROVED, 'allow_download' => true, 'allow_remix' => true, 'published_at' => now()->subHour(), ], Arr::except($attributes, ['category', 'template']))); } it('supports likes favorites saves and remix lineage for published cards', function (): void { $creator = User::factory()->create(); $viewer = User::factory()->create(); $card = v2PublishedCard($creator, ['slug' => 'remix-source-card']); $this->actingAs($viewer) ->postJson(route('api.cards.like', ['id' => $card->id])) ->assertOk() ->assertJsonPath('liked', true); $this->actingAs($viewer) ->postJson(route('api.cards.favorite', ['id' => $card->id])) ->assertOk() ->assertJsonPath('favorited', true); $this->actingAs($viewer) ->postJson(route('api.cards.save', ['id' => $card->id])) ->assertOk() ->assertJsonPath('ok', true); $remix = $this->actingAs($viewer) ->postJson(route('api.cards.remix', ['id' => $card->id])) ->assertCreated() ->assertJsonPath('data.lineage.original_card_id', $card->id); $remixedCard = NovaCard::query()->findOrFail((int) $remix->json('data.id')); expect($card->fresh()->likes_count)->toBe(1) ->and($card->fresh()->favorites_count)->toBe(1) ->and($card->fresh()->saves_count)->toBe(1) ->and($card->fresh()->remixes_count)->toBe(1) ->and($remixedCard->original_card_id)->toBe($card->id) ->and(NovaCardCollection::query()->where('user_id', $viewer->id)->exists())->toBeTrue(); }); it('returns lineage data for a published card via the api', function (): void { $creator = User::factory()->create(); $root = v2PublishedCard($creator, ['slug' => 'api-lineage-root', 'title' => 'API Lineage Root']); $remix = v2PublishedCard($creator, [ 'slug' => 'api-lineage-remix', 'title' => 'API Lineage Remix', 'original_card_id' => $root->id, 'root_card_id' => $root->id, ]); $this->getJson(route('api.cards.lineage', ['id' => $remix->id])) ->assertOk() ->assertJsonPath('data.root_card.id', $root->id) ->assertJsonPath('data.trail.0.id', $root->id) ->assertJsonPath('data.card.id', $remix->id); }); it('supports the literal spec-style api aliases for v2 card flows', function (): void { $creator = User::factory()->create(); $viewer = User::factory()->create(); $card = v2PublishedCard($creator, ['slug' => 'alias-source-card', 'title' => 'Alias Source']); $challenge = NovaCardChallenge::query()->create([ 'slug' => 'alias-challenge', 'title' => 'Alias Challenge', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, ]); $this->actingAs($viewer) ->postJson('/api/cards/' . $card->id . '/like') ->assertOk() ->assertJsonPath('liked', true); $this->actingAs($viewer) ->postJson('/api/cards/' . $card->id . '/save') ->assertOk() ->assertJsonPath('ok', true); $this->actingAs($viewer) ->postJson('/api/cards/' . $card->id . '/remix') ->assertCreated(); $owned = v2PublishedCard($viewer, ['slug' => 'alias-owned-card', 'title' => 'Alias Owned']); $this->actingAs($viewer) ->postJson('/api/cards/challenges/' . $challenge->id . '/submit', ['card_id' => $owned->id]) ->assertOk() ->assertJsonPath('entry.challenge_id', $challenge->id); $this->getJson('/api/cards/' . $card->id . '/lineage') ->assertOk(); }); it('supports collection metadata and item management via the v2 api collection endpoints', function (): void { $user = User::factory()->create(); $card = v2PublishedCard(User::factory()->create(), ['slug' => 'collection-endpoint-card']); $create = $this->actingAs($user) ->postJson(route('api.cards.collections.store'), [ 'name' => 'My Picks', 'visibility' => 'public', ]) ->assertCreated(); $collectionId = (int) $create->json('collection.id'); $this->actingAs($user) ->patchJson('/api/cards/collections/' . $collectionId, [ 'name' => 'Updated Picks', 'slug' => 'updated-picks', 'visibility' => 'public', ]) ->assertOk() ->assertJsonPath('collection.slug', 'updated-picks'); $this->actingAs($user) ->postJson('/api/cards/collections/' . $collectionId . '/items', [ 'card_id' => $card->id, 'note' => 'Pinned', ]) ->assertCreated() ->assertJsonPath('collection.items.0.card.id', $card->id); $this->actingAs($user) ->deleteJson('/api/cards/collections/' . $collectionId . '/items/' . $card->id) ->assertOk(); }); it('lists challenge, asset, and template resources through the v2 api', function (): void { $user = User::factory()->create(); NovaCardChallenge::query()->create([ 'slug' => 'resource-check', 'title' => 'Resource Check', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, ]); $this->actingAs($user)->getJson('/api/cards/challenges')->assertOk(); $this->actingAs($user)->getJson('/api/cards/assets')->assertOk(); $this->actingAs($user)->getJson('/api/cards/templates')->assertOk(); }); it('recomputes comments_count when card comments change', function (): void { $creator = User::factory()->create(); $commenter = User::factory()->create(); $card = v2PublishedCard($creator, ['slug' => 'comment-counter-card']); NovaCardComment::query()->create([ 'card_id' => $card->id, 'user_id' => $commenter->id, 'body' => 'First comment', 'rendered_body' => 'First comment', 'status' => 'visible', ]); app(\App\Services\NovaCards\NovaCardTrendingService::class)->refreshCard($card->fresh()); expect($card->fresh()->comments_count)->toBe(1); }); it('upgrades legacy v1 project json into the v2 editor shape when editing', function (): void { $user = User::factory()->create(); $category = v2Category(); $template = v2Template(); $card = NovaCard::query()->create([ 'user_id' => $user->id, 'category_id' => $category->id, 'template_id' => $template->id, 'title' => 'Legacy Card', 'slug' => 'legacy-card', 'quote_text' => 'Legacy quote', 'format' => NovaCard::FORMAT_SQUARE, 'project_json' => [ 'schema_version' => 1, 'content' => [ 'title' => 'Legacy Card', 'quote_text' => 'Legacy quote', ], 'background' => [ 'type' => 'gradient', 'gradient_preset' => 'midnight-nova', ], ], 'schema_version' => 1, 'background_type' => 'gradient', 'visibility' => NovaCard::VISIBILITY_PRIVATE, 'status' => NovaCard::STATUS_DRAFT, 'moderation_status' => NovaCard::MOD_PENDING, 'allow_download' => true, ]); $response = $this->actingAs($user) ->patchJson(route('api.cards.drafts.update', ['id' => $card->id]), [ 'quote_author' => 'Legacy Author', ]) ->assertOk(); expect($response->json('data.schema_version'))->toBe(2) ->and($response->json('data.project_json.text_blocks.0.type'))->toBe('title') ->and(NovaCard::query()->findOrFail($card->id)->schema_version)->toBe(2); }); it('duplicates an owned card into a fresh draft without remix lineage', function (): void { $user = User::factory()->create(); $card = v2PublishedCard($user, ['slug' => 'duplicate-source-card', 'title' => 'Duplicate Source']); $response = $this->actingAs($user) ->postJson(route('api.cards.duplicate', ['id' => $card->id])) ->assertCreated(); $duplicate = NovaCard::query()->findOrFail((int) $response->json('data.id')); expect($duplicate->user_id)->toBe($user->id) ->and($duplicate->title)->toBe('Copy of Duplicate Source') ->and($duplicate->status)->toBe(NovaCard::STATUS_DRAFT) ->and($duplicate->original_card_id)->toBeNull() ->and($duplicate->root_card_id)->toBeNull(); }); it('restores an earlier draft version through the v2 versions api', function (): void { $user = User::factory()->create(); $category = v2Category(); $template = v2Template(); $create = $this->actingAs($user)->postJson(route('api.cards.drafts.store'), [ 'title' => 'Versioned Card', 'quote_text' => 'First version.', 'category_id' => $category->id, 'template_id' => $template->id, ])->assertCreated(); $cardId = (int) $create->json('data.id'); $this->actingAs($user)->postJson(route('api.cards.drafts.autosave', ['id' => $cardId]), [ 'quote_text' => 'Second version.', ])->assertOk(); $versions = $this->actingAs($user) ->getJson(route('api.cards.drafts.versions', ['id' => $cardId])) ->assertOk(); $firstVersionId = (int) collect($versions->json('data'))->last()['id']; $this->actingAs($user) ->postJson(route('api.cards.drafts.restore', ['id' => $cardId, 'versionId' => $firstVersionId])) ->assertOk() ->assertJsonPath('data.quote_text', 'First version.'); expect(NovaCard::query()->findOrFail($cardId)->quote_text)->toBe('First version.'); }); it('submits a published card to a challenge and renders the new public v2 routes', function (): void { $user = User::factory()->create(['username' => 'v2creator']); $card = v2PublishedCard($user, ['slug' => 'challenge-entry-card']); $challenge = NovaCardChallenge::query()->create([ 'slug' => 'weekly-clarity', 'title' => 'Weekly Clarity', 'description' => 'Make a clean editorial card about clarity.', 'prompt' => 'Design a card that turns one strong sentence into an editorial object.', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, 'featured' => true, 'starts_at' => now()->subDay(), 'ends_at' => now()->addWeek(), ]); $this->actingAs($user) ->postJson(route('api.cards.challenges.submit', ['challengeId' => $challenge->id, 'id' => $card->id])) ->assertOk() ->assertJsonPath('entry.challenge_id', $challenge->id); expect($card->fresh()->challenge_entries_count)->toBe(1); $this->get(route('cards.popular'))->assertOk(); $this->get(route('cards.remixed'))->assertOk(); $this->get(route('cards.challenges'))->assertOk()->assertSee('Weekly Clarity'); $this->get(route('cards.challenges.show', ['slug' => $challenge->slug]))->assertOk()->assertSee($card->title); $this->get(route('cards.templates'))->assertOk(); $this->get(route('cards.assets'))->assertOk(); });