optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardAssetPack;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardCollection;
use App\Models\NovaCardCollectionItem;
use App\Models\NovaCardTemplate;
use App\Models\Report;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
function adminCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'admin-category-' . Str::lower(Str::random(6)),
'name' => 'Admin Category',
'description' => 'Admin category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function adminCardTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'admin-template-' . Str::lower(Str::random(6)),
'name' => 'Admin Template',
'description' => 'Admin 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'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function moderatedCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? adminCardCategory();
$template = $attributes['template'] ?? adminCardTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Moderation Card',
'slug' => 'moderation-card-' . Str::lower(Str::random(6)),
'quote_text' => 'A card waiting for moderation.',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => ['title' => 'Moderation Card', 'quote_text' => 'A card waiting for moderation.'],
'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_PENDING,
'featured' => false,
'allow_download' => true,
'published_at' => now()->subMinutes(10),
], Arr::except($attributes, ['category', 'template'])));
}
it('blocks non staff users from the cards admin area', function (): void {
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)
->get(route('cp.cards.index'))
->assertForbidden();
});
it('renders the cards admin index for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create(['username' => 'spotlightmaker', 'nova_featured_creator' => true]);
$card = moderatedCard($creator, [
'title' => 'Needs Review',
'project_json' => [
'content' => ['title' => 'Needs Review', 'quote_text' => 'A card waiting for moderation.'],
'moderation' => [
'source' => 'publish_heuristics',
'flagged' => true,
'reasons' => ['duplicate_content'],
'override' => [
'moderation_status' => 'flagged',
'disposition' => 'escalated_for_review',
'disposition_label' => 'Escalated for review',
'source' => 'report_queue',
'actor_username' => 'modone',
'updated_at' => now()->subMinutes(5)->toISOString(),
],
'override_history' => [
[
'moderation_status' => 'flagged',
'disposition' => 'escalated_for_review',
'disposition_label' => 'Escalated for review',
'source' => 'report_queue',
'actor_username' => 'modone',
'updated_at' => now()->subMinutes(5)->toISOString(),
],
[
'moderation_status' => 'pending',
'disposition' => 'returned_to_pending',
'disposition_label' => 'Returned to pending',
'source' => 'admin_card_update',
'actor_username' => 'modtwo',
'updated_at' => now()->subMinutes(12)->toISOString(),
],
],
],
],
]);
Report::query()->create([
'reporter_id' => $creator->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Spam remix bait',
'status' => 'open',
]);
$this->actingAs($admin)
->get(route('cp.cards.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsAdminIndex')
->where('cards.data.0.title', 'Needs Review')
->where('cards.data.0.moderation_reasons.0', 'duplicate_content')
->where('cards.data.0.moderation_reason_labels.0', 'Duplicate content')
->where('cards.data.0.moderation_override_history.1.disposition_label', 'Returned to pending')
->where('moderationDispositionOptions.approved.1.value', 'approved_with_watch')
->where('featuredCreators.0.username', 'spotlightmaker')
->where('featuredCreators.0.nova_featured_creator', true)
->where('reportingQueue.label', 'Nova Cards report queue')
->where('reportingQueue.pending', 1)
->where('endpoints.updateCreatorPattern', route('cp.cards.creators.update', ['user' => '__CREATOR__']))
->where('endpoints.moderateReportTargetPattern', route('api.admin.reports.moderate-target', ['report' => '__REPORT__']))
->where('endpoints.templates', route('cp.cards.templates.index')));
});
it('allows admins to update card moderation fields', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create();
$card = moderatedCard($creator);
$this->actingAs($admin)
->patchJson(route('cp.cards.update', ['card' => $card->id]), [
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'disposition' => 'approved_with_watch',
'featured' => true,
'allow_remix' => false,
])
->assertOk()
->assertJsonPath('card.featured', true)
->assertJsonPath('card.moderation_status', NovaCard::MOD_APPROVED)
->assertJsonPath('card.moderation_override.source', 'admin_card_update')
->assertJsonPath('card.moderation_override.disposition', 'approved_with_watch')
->assertJsonPath('card.moderation_override.disposition_label', 'Approved with watch')
->assertJsonPath('card.moderation_override_history.0.disposition_label', 'Approved with watch')
->assertJsonPath('card.moderation_override.actor_user_id', $admin->id)
->assertJsonPath('card.allow_remix', false);
$fresh = $card->fresh();
expect($fresh->featured)->toBeTrue();
expect($fresh->moderation_status)->toBe(NovaCard::MOD_APPROVED);
expect($fresh->allow_remix)->toBeFalse();
expect($fresh->project_json['moderation']['override']['source'] ?? null)->toBe('admin_card_update')
->and($fresh->project_json['moderation']['override']['disposition'] ?? null)->toBe('approved_with_watch');
});
it('allows admins to feature creators for editorial surfacing', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create(['username' => 'creatorflag']);
moderatedCard($creator, [
'title' => 'Eligible Creator Card',
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
]);
$this->actingAs($admin)
->patchJson(route('cp.cards.creators.update', ['user' => $creator->id]), [
'nova_featured_creator' => true,
])
->assertOk()
->assertJsonPath('creator.username', 'creatorflag')
->assertJsonPath('creator.nova_featured_creator', true)
->assertJsonPath('creator.public_cards_count', 1);
expect($creator->fresh()->nova_featured_creator)->toBeTrue();
});
it('allows admins to create categories and templates', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->postJson(route('cp.cards.categories.store'), [
'slug' => 'serenity',
'name' => 'Serenity',
'description' => 'Peaceful cards',
'active' => true,
'order_num' => 3,
])
->assertOk()
->assertJsonPath('category.slug', 'serenity');
$this->actingAs($admin)
->postJson(route('cp.cards.templates.store'), [
'slug' => 'calm-quote',
'name' => 'Calm Quote',
'description' => 'Soft centered quote card',
'supported_formats' => ['square', 'story'],
'active' => true,
'official' => true,
'order_num' => 1,
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
])
->assertOk()
->assertJsonPath('template.slug', 'calm-quote')
->assertJsonPath('template.supported_formats.1', 'story');
expect(NovaCardCategory::query()->where('slug', 'serenity')->exists())->toBeTrue();
expect(NovaCardTemplate::query()->where('slug', 'calm-quote')->exists())->toBeTrue();
});
it('renders the template admin page for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
adminCardTemplate(['slug' => 'editorial', 'name' => 'Editorial']);
$this->actingAs($admin)
->get(route('cp.cards.templates.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsTemplateAdmin')
->where('templates.0.slug', 'editorial')
->where('endpoints.cards', route('cp.cards.index')));
});
it('renders and manages asset packs and challenges for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create();
$winner = moderatedCard($creator, [
'slug' => 'winner-card',
'title' => 'Winner Card',
'moderation_status' => NovaCard::MOD_APPROVED,
]);
NovaCardAssetPack::query()->create([
'slug' => 'official-pack',
'name' => 'Official Pack',
'description' => 'Admin managed pack',
'type' => 'asset',
'manifest_json' => ['items' => [['key' => 'spark', 'label' => 'Spark', 'glyph' => '✦']]],
'official' => true,
'active' => true,
'order_num' => 0,
]);
NovaCardChallenge::query()->create([
'user_id' => $admin->id,
'slug' => 'editorial-week',
'title' => 'Editorial Week',
'description' => 'Admin challenge',
'prompt' => 'Create a sharp editorial quote card.',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => true,
'winner_card_id' => $winner->id,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
$this->actingAs($admin)
->get(route('cp.cards.asset-packs.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsAssetPackAdmin')
->where('packs.0.slug', 'official-pack'));
$this->actingAs($admin)
->get(route('cp.cards.challenges.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsChallengeAdmin')
->where('challenges.0.slug', 'editorial-week'));
$this->actingAs($admin)
->postJson(route('cp.cards.asset-packs.store'), [
'slug' => 'story-pack',
'name' => 'Story Pack',
'description' => 'Pack for stories',
'type' => 'template',
'manifest_json' => ['templates' => ['story-vertical']],
'official' => true,
'active' => true,
'order_num' => 1,
])
->assertOk()
->assertJsonPath('pack.slug', 'story-pack');
$this->actingAs($admin)
->postJson(route('cp.cards.challenges.store'), [
'slug' => 'clarity-run',
'title' => 'Clarity Run',
'description' => 'A clean clarity challenge.',
'prompt' => 'Use space and contrast.',
'rules_json' => ['max_entries_per_user' => 1],
'status' => 'active',
'official' => true,
'featured' => false,
'winner_card_id' => $winner->id,
'starts_at' => now()->toISOString(),
'ends_at' => now()->addWeek()->toISOString(),
])
->assertOk()
->assertJsonPath('challenge.slug', 'clarity-run');
expect(NovaCardAssetPack::query()->where('slug', 'story-pack')->exists())->toBeTrue();
expect(NovaCardChallenge::query()->where('slug', 'clarity-run')->exists())->toBeTrue();
});
it('renders and manages official collections for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$cardOwner = User::factory()->create();
$card = moderatedCard($cardOwner, ['slug' => 'collection-managed-card', 'title' => 'Collection Managed Card', 'moderation_status' => NovaCard::MOD_APPROVED]);
$collection = NovaCardCollection::query()->create([
'user_id' => $admin->id,
'slug' => 'official-launch',
'name' => 'Official Launch',
'description' => 'Initial official collection.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true,
'featured' => false,
'cards_count' => 0,
]);
$this->actingAs($admin)
->get(route('cp.cards.collections.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsCollectionAdmin')
->where('collections.0.slug', 'official-launch'));
$this->actingAs($admin)
->postJson(route('cp.cards.collections.store'), [
'user_id' => $admin->id,
'slug' => 'staff-picks',
'name' => 'Staff Picks',
'description' => 'Editorial picks.',
'visibility' => 'public',
'official' => true,
'featured' => true,
])
->assertOk()
->assertJsonPath('collection.slug', 'staff-picks')
->assertJsonPath('collection.featured', true);
$this->actingAs($admin)
->postJson(route('cp.cards.collections.cards.store', ['collection' => $collection->id]), [
'card_id' => $card->id,
'note' => 'Lead card',
])
->assertOk()
->assertJsonPath('collection.items.0.card.id', $card->id);
expect(NovaCardCollectionItem::query()->where('collection_id', $collection->id)->where('card_id', $card->id)->exists())->toBeTrue();
expect(NovaCardCollection::query()->where('slug', 'staff-picks')->value('featured'))->toBeTrue();
});

View File

@@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
use App\Http\Requests\NovaCards\UploadNovaCardBackgroundRequest;
use App\Events\NovaCards\NovaCardAutosaved;
use App\Events\NovaCards\NovaCardBackgroundUploaded;
use App\Events\NovaCards\NovaCardCreated;
use App\Events\NovaCards\NovaCardPublished;
use App\Events\NovaCards\NovaCardTemplateSelected;
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardBackground;
use App\Models\NovaCardTemplate;
use App\Models\User;
use App\Services\NovaCards\NovaCardRenderService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
function draftCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'draft-category-' . Str::lower(Str::random(6)),
'name' => 'Draft Category',
'description' => 'Draft category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function draftTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'draft-template-' . Str::lower(Str::random(6)),
'name' => 'Draft Template',
'description' => 'Draft 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 draftCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? draftCategory();
$template = $attributes['template'] ?? draftTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Draft Card',
'slug' => 'draft-card-' . Str::lower(Str::random(6)),
'quote_text' => 'A draft quote for editing.',
'quote_author' => 'Test Author',
'quote_source' => 'Notebook',
'description' => 'Draft description',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => [
'title' => 'Draft Card',
'quote_text' => 'A draft quote for editing.',
'quote_author' => 'Test Author',
'quote_source' => 'Notebook',
],
'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_PRIVATE,
'status' => NovaCard::STATUS_DRAFT,
'moderation_status' => NovaCard::MOD_PENDING,
'featured' => false,
'allow_download' => true,
], Arr::except($attributes, ['category', 'template'])));
}
afterEach(function (): void {
\Mockery::close();
});
it('creates and fetches a draft through the draft api', function (): void {
Event::fake([NovaCardCreated::class, NovaCardTemplateSelected::class]);
$user = User::factory()->create();
$category = draftCategory();
$template = draftTemplate();
$create = $this->actingAs($user)
->postJson(route('api.cards.drafts.store'), [
'title' => 'First Draft',
'quote_text' => 'The first saved quote.',
'format' => 'square',
'category_id' => $category->id,
'template_id' => $template->id,
'tags' => ['focus', 'clarity'],
]);
$create->assertCreated()
->assertJsonPath('data.title', 'First Draft')
->assertJsonPath('data.format', 'square')
->assertJsonCount(2, 'data.tags');
$cardId = (int) $create->json('data.id');
$this->actingAs($user)
->getJson(route('api.cards.drafts.show', ['id' => $cardId]))
->assertOk()
->assertJsonPath('data.id', $cardId)
->assertJsonPath('data.project_json.content.quote_text', 'The first saved quote.');
Event::assertDispatched(NovaCardCreated::class);
Event::assertDispatched(NovaCardTemplateSelected::class);
});
it('autosaves project json changes for the draft owner', function (): void {
Event::fake([NovaCardAutosaved::class]);
$user = User::factory()->create();
$card = draftCard($user);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.autosave', ['id' => $card->id]), [
'title' => 'Updated Draft',
'quote_text' => 'Updated quote text for autosave.',
'editor_mode_last_used' => 'quick',
'project_json' => [
'text_blocks' => [
['key' => 'quote', 'type' => 'quote', 'text' => 'Updated quote text for autosave.', 'enabled' => true],
['key' => 'title', 'type' => 'title', 'text' => 'Updated Draft', 'enabled' => true],
['key' => 'body-1', 'type' => 'body', 'text' => 'Supporting body copy.', 'enabled' => true],
],
'layout' => [
'alignment' => 'left',
'position' => 'top',
],
'typography' => [
'quote_size' => 88,
'text_color' => '#ffffff',
],
],
]);
$response->assertOk()
->assertJsonPath('data.title', 'Updated Draft')
->assertJsonPath('data.project_json.layout.alignment', 'left')
->assertJsonPath('data.project_json.text_blocks.0.type', 'quote')
->assertJsonPath('data.editor_mode_last_used', 'quick')
->assertJsonPath('meta.saved_at', fn ($value): bool => is_string($value) && $value !== '');
expect($card->fresh()->project_json['layout']['alignment'])->toBe('left')
->and($card->fresh()->project_json['text_blocks'][0]['type'])->toBe('quote')
->and($card->fresh()->editor_mode_last_used)->toBe('quick');
Event::assertDispatched(NovaCardAutosaved::class);
});
it('returns snapshot payloads in the draft versions api for compare views', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$this->actingAs($user)
->postJson(route('api.cards.drafts.autosave', ['id' => $card->id]), [
'project_json' => [
'text_blocks' => [
['key' => 'title', 'type' => 'title', 'text' => 'Versioned title', 'enabled' => true],
['key' => 'quote', 'type' => 'quote', 'text' => 'Versioned quote', 'enabled' => true],
],
'layout' => [
'layout' => 'minimal',
],
],
])
->assertOk();
$this->actingAs($user)
->getJson(route('api.cards.drafts.versions', ['id' => $card->id]))
->assertOk()
->assertJsonPath('data.0.snapshot_json.text_blocks.0.type', 'title')
->assertJsonPath('data.0.snapshot_json.layout.layout', 'minimal');
});
it('prevents another user from editing a draft they do not own', function (): void {
$owner = User::factory()->create();
$other = User::factory()->create();
$card = draftCard($owner);
$this->actingAs($other)
->patchJson(route('api.cards.drafts.update', ['id' => $card->id]), [
'title' => 'Illegal update',
])
->assertNotFound();
});
it('publishes a draft when title and quote are present', function (): void {
Event::fake([NovaCardPublished::class]);
$user = User::factory()->create();
$card = draftCard($user, ['title' => 'Publishable Card', 'quote_text' => 'Ready for publishing.']);
$renderService = \Mockery::mock(NovaCardRenderService::class);
$renderService->shouldReceive('render')
->once()
->andReturn([
'preview_path' => 'nova-cards/previews/test.webp',
'preview_width' => 1080,
'preview_height' => 1080,
]);
app()->instance(NovaCardRenderService::class, $renderService);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.publish', ['id' => $card->id]), []);
$response->assertOk()
->assertJsonPath('data.status', NovaCard::STATUS_PUBLISHED)
->assertJsonPath('data.moderation_status', NovaCard::MOD_APPROVED);
expect($card->fresh()->status)->toBe(NovaCard::STATUS_PUBLISHED);
Event::assertDispatched(NovaCardPublished::class);
});
it('flags risky duplicate publishes for moderation review', function (): void {
Event::fake([NovaCardPublished::class]);
$user = User::factory()->create();
NovaCard::query()->create([
'user_id' => $user->id,
'category_id' => draftCategory()->id,
'template_id' => draftTemplate()->id,
'title' => 'Repeated Card',
'slug' => 'repeated-card-existing',
'quote_text' => 'This quote already exists publicly.',
'quote_author' => 'Existing Author',
'quote_source' => 'Existing Source',
'description' => 'Existing public card',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => ['content' => ['title' => 'Repeated Card', 'quote_text' => 'This quote already exists publicly.']],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'allow_download' => true,
'published_at' => now()->subDay(),
]);
$card = draftCard($user, [
'title' => 'Repeated Card',
'quote_text' => 'This quote already exists publicly.',
]);
$renderService = \Mockery::mock(NovaCardRenderService::class);
$renderService->shouldReceive('render')
->once()
->andReturn([
'preview_path' => 'nova-cards/previews/risky.webp',
'preview_width' => 1080,
'preview_height' => 1080,
]);
app()->instance(NovaCardRenderService::class, $renderService);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.publish', ['id' => $card->id]), []);
$response->assertOk()
->assertJsonPath('data.status', NovaCard::STATUS_PUBLISHED)
->assertJsonPath('data.moderation_status', NovaCard::MOD_FLAGGED)
->assertJsonPath('data.moderation_reasons.0', 'duplicate_content')
->assertJsonPath('data.moderation_reason_labels.0', 'Duplicate content');
expect($card->fresh()->moderation_status)->toBe(NovaCard::MOD_FLAGGED)
->and($card->fresh()->project_json['moderation']['reasons'] ?? [])->toContain('duplicate_content');
Event::assertDispatched(NovaCardPublished::class);
});
it('rejects publish when required fields are missing', function (): void {
$user = User::factory()->create();
$card = draftCard($user, ['title' => 'Ok title', 'quote_text' => 'Short quote']);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.publish', ['id' => $card->id]), [
'title' => '',
]);
$response->assertStatus(422);
});
it('uploads a background image and attaches it to the draft', function (): void {
Event::fake([NovaCardBackgroundUploaded::class]);
Storage::fake('local');
Storage::fake('public');
config()->set('nova_cards.storage.private_disk', 'local');
config()->set('nova_cards.storage.public_disk', 'public');
$user = User::factory()->create();
$card = draftCard($user);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.background', ['id' => $card->id]), [
'background' => UploadedFile::fake()->image('background.png', 1400, 1000),
]);
$response->assertCreated()
->assertJsonPath('data.background_type', 'upload')
->assertJsonPath('data.project_json.background.type', 'upload')
->assertJsonPath('background.width', 1400)
->assertJsonPath('background.height', 1000);
$backgroundId = (int) $response->json('background.id');
$background = NovaCardBackground::query()->findOrFail($backgroundId);
Storage::disk('local')->assertExists($background->original_path);
Storage::disk('public')->assertExists($background->processed_path);
expect($card->fresh()->background_image_id)->toBe($backgroundId);
Event::assertDispatched(NovaCardBackgroundUploaded::class);
});
it('rejects invalid background uploads', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$this->actingAs($user)
->postJson(route('api.cards.drafts.background', ['id' => $card->id]), [
'background' => UploadedFile::fake()->create('background.txt', 8, 'text/plain'),
])
->assertStatus(422)
->assertJsonValidationErrors(['background']);
$this->actingAs($user)
->postJson(route('api.cards.drafts.background', ['id' => $card->id]), [
'background' => UploadedFile::fake()->image('small.png', 320, 320),
])
->assertStatus(422)
->assertJsonValidationErrors(['background']);
});
it('rejects malformed background uploads without throwing', function (): void {
$request = new UploadNovaCardBackgroundRequest();
$tempPath = tempnam(sys_get_temp_dir(), 'nova-card-background-');
file_put_contents($tempPath, 'broken');
$invalidUpload = new class($tempPath) extends UploadedFile {
public function __construct(string $path)
{
parent::__construct($path, 'broken.png', 'image/png', \UPLOAD_ERR_NO_FILE, true);
}
public function isValid(): bool
{
return false;
}
public function getRealPath(): string|false
{
return '';
}
public function getPathname(): string
{
return '';
}
};
$validator = validator([
'background' => $invalidUpload,
], $request->rules());
expect(fn() => $validator->fails())->not->toThrow(ValueError::class);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->keys())->toContain('background');
@unlink($tempPath);
});
it('renders a draft preview through the render endpoint', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$renderService = \Mockery::mock(NovaCardRenderService::class);
$renderService->shouldReceive('render')
->once()
->andReturn([
'preview_path' => 'cards/previews/' . $user->id . '/rendered.webp',
'og_path' => 'cards/previews/' . $user->id . '/rendered-og.jpg',
'width' => 1080,
'height' => 1080,
]);
app()->instance(NovaCardRenderService::class, $renderService);
$this->actingAs($user)
->postJson(route('api.cards.drafts.render', ['id' => $card->id]))
->assertOk()
->assertJsonPath('data.id', $card->id)
->assertJsonPath('render.preview_path', 'cards/previews/' . $user->id . '/rendered.webp')
->assertJsonPath('render.width', 1080)
->assertJsonPath('render.height', 1080);
});
it('deletes an unpublished draft through the draft api', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$this->actingAs($user)
->deleteJson(route('api.cards.drafts.destroy', ['id' => $card->id]))
->assertOk()
->assertJsonPath('ok', true);
expect(NovaCard::query()->whereKey($card->id)->exists())->toBeFalse();
expect(NovaCard::withTrashed()->whereKey($card->id)->exists())->toBeTrue();
});
it('does not allow deleting a published public card through the draft api', function (): void {
$user = User::factory()->create();
$card = draftCard($user, [
'status' => NovaCard::STATUS_PUBLISHED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'moderation_status' => NovaCard::MOD_APPROVED,
'published_at' => now()->subMinute(),
]);
$this->actingAs($user)
->deleteJson(route('api.cards.drafts.destroy', ['id' => $card->id]))
->assertStatus(422)
->assertJsonPath('message', 'Published cards cannot be deleted from the draft API.');
expect(NovaCard::query()->whereKey($card->id)->exists())->toBeTrue();
});

View File

@@ -0,0 +1,621 @@
<?php
declare(strict_types=1);
use App\Events\NovaCards\NovaCardDownloaded;
use App\Events\NovaCards\NovaCardShared;
use App\Events\NovaCards\NovaCardViewed;
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardComment;
use App\Models\NovaCardCollection;
use App\Models\NovaCardCollectionItem;
use App\Models\NovaCardCreatorPreset;
use App\Models\NovaCardTag;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
function novaCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->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();
});

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardComment;
use App\Models\NovaCardTemplate;
use App\Models\Report;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
function reportCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'report-category-' . Str::lower(Str::random(6)),
'name' => 'Report Category',
'description' => 'Report category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function reportCardTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'report-template-' . Str::lower(Str::random(6)),
'name' => 'Report Template',
'description' => 'Report 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'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function reportableCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? reportCardCategory();
$template = $attributes['template'] ?? reportCardTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Reportable Card',
'slug' => 'reportable-card-' . Str::lower(Str::random(6)),
'quote_text' => 'Card that can be reported.',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => ['title' => 'Reportable Card', 'quote_text' => 'Card that can be reported.'],
'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' => 2,
'schema_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()->subMinutes(5),
], Arr::except($attributes, ['category', 'template'])));
}
it('accepts nova card, challenge, and challenge entry reports through the shared intake endpoint', function (): void {
$reporter = User::factory()->create(['username' => 'reporter']);
$creator = User::factory()->create(['username' => 'creator']);
$card = reportableCard($creator);
$challenge = NovaCardChallenge::query()->create([
'user_id' => $creator->id,
'slug' => 'reporting-challenge',
'title' => 'Reporting Challenge',
'description' => 'Challenge description',
'prompt' => 'Challenge prompt',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => false,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
$entry = NovaCardChallengeEntry::query()->create([
'challenge_id' => $challenge->id,
'card_id' => $card->id,
'user_id' => $creator->id,
'status' => NovaCardChallengeEntry::STATUS_SUBMITTED,
'note' => 'Challenge entry note',
]);
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Misleading card',
])
->assertCreated();
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card_challenge',
'target_id' => $challenge->id,
'reason' => 'Bad challenge brief',
])
->assertCreated();
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card_challenge_entry',
'target_id' => $entry->id,
'reason' => 'Spam challenge entry',
'details' => 'This entry is unrelated to the prompt.',
])
->assertCreated();
expect(Report::query()->where('reporter_id', $reporter->id)->count())->toBe(3);
});
it('accepts nova card comment reports through the shared intake endpoint', function (): void {
$reporter = User::factory()->create(['username' => 'commentreporter']);
$creator = User::factory()->create(['username' => 'commentreportcreator']);
$card = reportableCard($creator, ['title' => 'Comment Report Card']);
$comment = NovaCardComment::query()->create([
'card_id' => $card->id,
'user_id' => $creator->id,
'body' => 'Questionable comment body.',
'rendered_body' => 'Questionable comment body.',
'status' => 'visible',
]);
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card_comment',
'target_id' => $comment->id,
'reason' => 'Abusive comment',
])
->assertCreated();
expect(Report::query()->where('target_type', 'nova_card_comment')->where('target_id', $comment->id)->exists())->toBeTrue();
});
it('filters nova card reports in the moderation queue and allows status transitions', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$reporter = User::factory()->create(['username' => 'queuereporter']);
$creator = User::factory()->create(['username' => 'queuecreator']);
$card = reportableCard($creator, ['title' => 'Queue Card']);
$challenge = NovaCardChallenge::query()->create([
'user_id' => $creator->id,
'slug' => 'queue-challenge',
'title' => 'Queue Challenge',
'prompt' => 'Queue prompt',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => false,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
$cardReport = Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Card spam',
'status' => 'open',
]);
Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card_challenge',
'target_id' => $challenge->id,
'reason' => 'Challenge abuse',
'status' => 'open',
]);
Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'user',
'target_id' => $creator->id,
'reason' => 'Unrelated user report',
'status' => 'open',
]);
$queue = $this->actingAs($admin)
->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'open']))
->assertOk();
expect($queue->json('meta.total'))->toBe(2)
->and(collect($queue->json('data'))->pluck('target.label')->all())->toContain('Queue Card', 'Queue Challenge');
$this->actingAs($admin)
->patchJson(route('api.admin.reports.update', ['report' => $cardReport->id]), [
'status' => 'reviewing',
])
->assertOk()
->assertJsonPath('report.status', 'reviewing')
->assertJsonPath('report.target.label', 'Queue Card');
$reviewing = $this->actingAs($admin)
->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'reviewing']))
->assertOk();
expect($reviewing->json('meta.total'))->toBe(1)
->and($cardReport->fresh()->status)->toBe('reviewing');
});
it('records moderator notes and report history entries from the moderation queue', function (): void {
$admin = User::factory()->create(['role' => 'admin', 'username' => 'auditadmin']);
$reporter = User::factory()->create(['username' => 'auditreporter']);
$creator = User::factory()->create(['username' => 'auditcreator']);
$card = reportableCard($creator, ['title' => 'Audit Queue Card']);
$report = Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Needs closer review',
'status' => 'open',
]);
$response = $this->actingAs($admin)
->patchJson(route('api.admin.reports.update', ['report' => $report->id]), [
'status' => 'reviewing',
'moderator_note' => 'Escalated to card moderation while we verify the prompt source.',
])
->assertOk();
$report->refresh();
expect($report->status)->toBe('reviewing')
->and($report->moderator_note)->toBe('Escalated to card moderation while we verify the prompt source.')
->and($report->last_moderated_by_id)->toBe($admin->id)
->and($report->historyEntries()->count())->toBe(1)
->and($response->json('report.history.0.summary'))->toContain('Status open -> reviewing')
->and($response->json('report.history.0.actor.username'))->toBe('auditadmin');
});
it('allows moderators to update the underlying nova card from a report row', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$reporter = User::factory()->create(['username' => 'targetreporter']);
$creator = User::factory()->create(['username' => 'targetcreator']);
$card = reportableCard($creator, [
'title' => 'Flaggable Queue Card',
'moderation_status' => NovaCard::MOD_PENDING,
'project_json' => [
'content' => ['title' => 'Flaggable Queue Card', 'quote_text' => 'Report queue card'],
'moderation' => [
'source' => 'publish_heuristics',
'flagged' => true,
'reasons' => ['self_remix_loop'],
],
],
]);
$report = Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Suspicious engagement bait',
'status' => 'reviewing',
]);
$this->actingAs($admin)
->postJson(route('api.admin.reports.moderate-target', ['report' => $report->id]), [
'action' => 'flag_card',
'disposition' => 'rights_review_required',
])
->assertOk()
->assertJsonPath('report.target.moderation_target.card_id', $card->id)
->assertJsonPath('report.target.moderation_target.moderation_status', NovaCard::MOD_FLAGGED)
->assertJsonPath('report.target.moderation_target.moderation_reasons.0', 'self_remix_loop')
->assertJsonPath('report.target.moderation_target.moderation_reason_labels.0', 'Self-remix loop')
->assertJsonPath('report.target.moderation_target.moderation_override.source', 'report_queue')
->assertJsonPath('report.target.moderation_target.moderation_override.disposition', 'rights_review_required')
->assertJsonPath('report.target.moderation_target.moderation_override.disposition_label', 'Rights review required')
->assertJsonPath('report.target.moderation_target.moderation_override_history.0.disposition_label', 'Rights review required')
->assertJsonPath('report.target.moderation_target.moderation_override.report_id', $report->id)
->assertJsonPath('report.history.0.action_type', 'target_moderated');
expect($card->fresh()->moderation_status)->toBe(NovaCard::MOD_FLAGGED)
->and($card->fresh()->project_json['moderation']['override']['source'] ?? null)->toBe('report_queue')
->and($card->fresh()->project_json['moderation']['override']['disposition'] ?? null)->toBe('rights_review_required')
->and($report->fresh()->historyEntries()->count())->toBe(1)
->and($report->fresh()->last_moderated_by_id)->toBe($admin->id);
});
it('includes nova card comment reports in the nova cards moderation queue', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$reporter = User::factory()->create(['username' => 'commentqueuereporter']);
$creator = User::factory()->create(['username' => 'commentqueuecreator']);
$card = reportableCard($creator, ['title' => 'Queue Comment Card']);
$comment = NovaCardComment::query()->create([
'card_id' => $card->id,
'user_id' => $creator->id,
'body' => 'Queue this comment too.',
'rendered_body' => 'Queue this comment too.',
'status' => 'visible',
]);
Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card_comment',
'target_id' => $comment->id,
'reason' => 'Comment harassment',
'status' => 'open',
]);
$queue = $this->actingAs($admin)
->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'open']))
->assertOk();
expect(collect($queue->json('data'))->pluck('target.type')->all())->toContain('nova_card_comment');
});
it('renders challenge reporting controls for authenticated viewers', function (): void {
$viewer = User::factory()->create(['username' => 'challengeviewer']);
$creator = User::factory()->create(['username' => 'challengecreator']);
$card = reportableCard($creator, ['title' => 'Challenge Card']);
$challenge = NovaCardChallenge::query()->create([
'user_id' => $creator->id,
'slug' => 'challenge-report-page',
'title' => 'Challenge Report Page',
'description' => 'Challenge description',
'prompt' => 'Challenge prompt',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => true,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
NovaCardChallengeEntry::query()->create([
'challenge_id' => $challenge->id,
'card_id' => $card->id,
'user_id' => $creator->id,
'status' => NovaCardChallengeEntry::STATUS_SUBMITTED,
]);
$response = $this->actingAs($viewer)
->get(route('cards.challenges.show', ['slug' => $challenge->slug]))
->assertOk();
expect($response->getContent())
->toContain('data-report-target-type="nova_card_challenge"')
->toContain('data-report-target-type="nova_card_challenge_entry"');
});

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardAsset;
use App\Models\NovaCardAssetPack;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardCollection;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
it('seeds official demo cards when the hook is enabled', function (): void {
config()->set('nova_cards.seed_demo_cards.enabled', true);
config()->set('nova_cards.seed_demo_cards.user.username', 'nova.cards');
config()->set('nova_cards.seed_demo_cards.user.email', 'nova-cards-demo@skinbase.test');
$this->seed(DatabaseSeeder::class);
$demoUser = User::query()->where('email', 'nova-cards-demo@skinbase.test')->first();
expect($demoUser)->not->toBeNull();
expect(NovaCard::query()->where('user_id', $demoUser->id)->count())->toBe(6);
expect(NovaCard::query()->where('user_id', $demoUser->id)->where('featured', true)->exists())->toBeTrue();
expect(NovaCard::query()->where('user_id', $demoUser->id)->where('status', NovaCard::STATUS_PUBLISHED)->count())->toBe(6);
expect(NovaCardCollection::query()->where('user_id', $demoUser->id)->where('official', true)->count())->toBeGreaterThanOrEqual(2);
expect(NovaCardChallenge::query()->where('user_id', $demoUser->id)->where('official', true)->count())->toBeGreaterThanOrEqual(2);
expect(NovaCardChallengeEntry::query()->count())->toBeGreaterThanOrEqual(6);
expect(NovaCardAssetPack::query()->where('official', true)->count())->toBeGreaterThanOrEqual(4);
expect(NovaCardAsset::query()->where('official', true)->count())->toBeGreaterThan(0);
});

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
function studioCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'studio-category-' . Str::lower(Str::random(6)),
'name' => 'Studio Category',
'description' => 'Studio category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function studioCardTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'studio-template-' . Str::lower(Str::random(6)),
'name' => 'Studio Template',
'description' => 'Studio 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', 'story'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function studioDraftCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? studioCardCategory();
$template = $attributes['template'] ?? studioCardTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Studio Draft',
'slug' => 'studio-draft-' . Str::lower(Str::random(6)),
'quote_text' => 'Studio draft quote text.',
'quote_author' => 'Studio Author',
'quote_source' => 'Studio Notes',
'description' => 'Studio draft description',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => [
'title' => 'Studio Draft',
'quote_text' => 'Studio draft quote text.',
'quote_author' => 'Studio Author',
'quote_source' => 'Studio Notes',
],
'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,
],
'background' => [
'type' => 'gradient',
'gradient_preset' => 'midnight-nova',
'gradient_colors' => ['#0f172a', '#1d4ed8'],
'overlay_style' => 'dark-soft',
'blur_level' => 0,
'opacity' => 50,
],
'decorations' => [],
],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PRIVATE,
'status' => NovaCard::STATUS_DRAFT,
'moderation_status' => NovaCard::MOD_PENDING,
'featured' => false,
'allow_download' => true,
], Arr::except($attributes, ['category', 'template'])));
}
it('requires authentication for studio card pages', function (): void {
foreach ([
route('studio.cards.index'),
route('studio.cards.create'),
] as $url) {
$this->get($url)->assertRedirect('/login');
}
});
it('renders the studio cards index with user stats and edit endpoints', function (): void {
$user = User::factory()->create();
studioDraftCard($user, ['title' => 'My Studio Draft']);
$this->actingAs($user)
->get(route('studio.cards.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardsIndex')
->where('stats.all', 1)
->where('stats.drafts', 1)
->where('cards.data.0.title', 'My Studio Draft')
->where('endpoints.create', route('studio.cards.create'))
->where('endpoints.draftStore', route('api.cards.drafts.store')));
});
it('renders the create editor with preview mode disabled', function (): void {
$user = User::factory()->create();
studioCardCategory(['slug' => 'affirmations', 'name' => 'Affirmations']);
studioCardTemplate(['slug' => 'bold-center', 'name' => 'Bold Center']);
$this->actingAs($user)
->get(route('studio.cards.create'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardEditor')
->where('previewMode', false)
->where('card', null)
->where('mobileSteps.0.key', 'format')
->where('mobileSteps.5.key', 'publish')
->where('endpoints.studioCards', route('studio.cards.index'))
->where('endpoints.draftStore', route('api.cards.drafts.store'))
->where('editorOptions.categories.0.slug', 'affirmations')
->where('editorOptions.templates.0.slug', 'bold-center'));
});
it('renders edit and preview studio pages only for the owner', function (): void {
$owner = User::factory()->create();
$other = User::factory()->create();
$card = studioDraftCard($owner, [
'title' => 'Owner Draft',
'editor_mode_last_used' => 'quick',
'project_json' => [
'content' => [
'title' => 'Owner Draft',
'quote_text' => 'Studio draft quote text.',
'quote_author' => 'Studio Author',
'quote_source' => 'Studio Notes',
],
'text_blocks' => [
['key' => 'title', 'type' => 'title', 'text' => 'Owner Draft', 'enabled' => true],
['key' => 'quote', 'type' => 'quote', 'text' => 'Studio draft quote text.', '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,
],
'background' => [
'type' => 'gradient',
'gradient_preset' => 'midnight-nova',
'gradient_colors' => ['#0f172a', '#1d4ed8'],
'overlay_style' => 'dark-soft',
'blur_level' => 0,
'opacity' => 50,
],
'source_context' => [
'editor_mode' => 'quick',
],
'decorations' => [],
],
]);
$card->versions()->create([
'user_id' => $owner->id,
'version_number' => 1,
'label' => 'Initial snapshot',
'snapshot_hash' => hash('sha256', 'owner-draft-v1'),
'snapshot_json' => $card->project_json,
]);
$this->actingAs($owner)
->get(route('studio.cards.edit', ['id' => $card->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardEditor')
->where('previewMode', false)
->where('card.id', $card->id)
->where('card.title', 'Owner Draft')
->where('card.editor_mode_last_used', 'quick')
->where('versions.0.snapshot_json.source_context.editor_mode', 'quick'));
$this->actingAs($owner)
->get(route('studio.cards.preview', ['id' => $card->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardEditor')
->where('previewMode', true)
->where('card.id', $card->id));
$this->actingAs($other)
->get(route('studio.cards.edit', ['id' => $card->id]))
->assertNotFound();
});
it('renders the studio card analytics page for the owner', function (): void {
$owner = User::factory()->create();
$card = studioDraftCard($owner, [
'title' => 'Analytics Card',
'status' => NovaCard::STATUS_PUBLISHED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'likes_count' => 4,
'saves_count' => 3,
'remixes_count' => 2,
'comments_count' => 5,
'views_count' => 12,
'trending_score' => 18.5,
'published_at' => now()->subHour(),
]);
$this->actingAs($owner)
->get(route('studio.cards.analytics', ['id' => $card->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardAnalytics')
->where('card.id', $card->id)
->where('analytics.likes', 4)
->where('analytics.comments', 5));
});

View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardComment;
use App\Models\NovaCardCollection;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
function v2Category(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->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();
});

View File

@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCreatorPreset;
use App\Models\NovaCardExport;
use App\Models\User;
use App\Services\NovaCards\NovaCardCreatorPresetService;
use App\Services\NovaCards\NovaCardProjectNormalizer;
use App\Services\NovaCards\NovaCardRelatedCardsService;
use App\Services\NovaCards\NovaCardRisingService;
use Illuminate\Support\Str;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function v3User(): User
{
return User::factory()->create();
}
function v3Card(User $user, array $attributes = []): NovaCard
{
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'uuid' => Str::uuid()->toString(),
'slug' => 'v3-card-' . Str::lower(Str::random(8)),
'title' => 'Nova v3 Card',
'quote_text' => 'A test quote for version three.',
'format' => 'square',
'status' => NovaCard::STATUS_PUBLISHED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'moderation_status' => NovaCard::MOD_APPROVED,
'published_at' => now(),
'schema_version' => 3,
'project_json' => [
'schema_version' => 3,
'meta' => ['editor' => 'nova-cards-v3'],
'content' => ['title' => 'Nova v3 Card', 'quote_text' => 'A test quote for version three.'],
'text_blocks' => [],
'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, 'line_height' => 1.2, 'shadow_preset' => 'soft', 'quote_mark_preset' => 'none', 'text_panel_style' => 'none'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#0f172a', '#1d4ed8']],
'canvas' => ['density' => 'standard', 'safe_zone' => true],
'frame' => ['preset' => 'none'],
'effects' => ['color_grade' => 'none', 'effect_preset' => 'none', 'intensity' => 50],
'export_preferences' => ['allow_export' => true, 'default_format' => 'preview'],
'source_context' => ['style_family' => null, 'palette_family' => null],
'decorations' => [],
'assets' => ['pack_ids' => [], 'template_pack_ids' => [], 'items' => []],
],
'allow_export' => true,
'allow_background_reuse' => false,
], $attributes));
}
// ─── Schema normalizer v3 tests ───────────────────────────────────────────────
describe('NovaCardProjectNormalizer v3', function (): void {
it('produces schema_version 3 for new projects', function (): void {
$normalizer = app(NovaCardProjectNormalizer::class);
$result = $normalizer->normalize(null, null, []);
expect($result['schema_version'])->toBe(3);
expect($result['meta']['editor'])->toBe('nova-cards-v3');
});
it('detects legacy v1/v2 projects as needing upgrade', function (): void {
$normalizer = app(NovaCardProjectNormalizer::class);
$v1 = ['schema_version' => 1, 'quote_text' => 'old quote'];
expect($normalizer->isLegacyProject($v1))->toBeTrue();
$v2 = ['schema_version' => 2, 'meta' => ['editor' => 'nova-cards-v2']];
expect($normalizer->isLegacyProject($v2))->toBeTrue();
$v3 = ['schema_version' => 3, 'meta' => ['editor' => 'nova-cards-v3']];
expect($normalizer->isLegacyProject($v3))->toBeFalse();
});
it('upgradeToV3 includes all v3 sections', function (): void {
$normalizer = app(NovaCardProjectNormalizer::class);
$v2 = [
'schema_version' => 2,
'meta' => ['editor' => 'nova-cards-v2'],
'layout' => ['layout' => 'centered'],
'background' => ['type' => 'gradient', 'gradient_colors' => ['#000', '#fff']],
'typography' => ['font_preset' => 'elegant-serif'],
];
$result = $normalizer->upgradeToV3($v2);
expect($result['schema_version'])->toBe(3);
expect($result['meta']['editor'])->toBe('nova-cards-v3');
// v2 content preserved
expect($result['layout']['layout'])->toBe('centered');
expect($result['background']['gradient_colors'])->toBe(['#000', '#fff']);
expect($result['typography']['font_preset'])->toBe('elegant-serif');
// v3 sections added
expect($result)->toHaveKey('canvas');
expect($result)->toHaveKey('frame');
expect($result)->toHaveKey('effects');
expect($result)->toHaveKey('export_preferences');
expect($result)->toHaveKey('source_context');
// v3 typography additions
expect($result['typography'])->toHaveKey('quote_mark_preset');
expect($result['typography'])->toHaveKey('text_panel_style');
});
it('normalizeForCard upgrades a v1 card to v3 on the fly', function (): void {
$user = v3User();
$card = NovaCard::query()->create([
'user_id' => $user->id,
'uuid' => Str::uuid()->toString(),
'slug' => 'legacy-v1-' . Str::lower(Str::random(6)),
'title' => 'Legacy card',
'quote_text' => 'An old-school quote.',
'format' => 'square',
'status' => NovaCard::STATUS_DRAFT,
'visibility' => NovaCard::VISIBILITY_PRIVATE,
'moderation_status' => NovaCard::MOD_PENDING,
'schema_version' => 1,
'project_json' => ['schema_version' => 1, 'quote_text' => 'An old-school quote.'],
]);
$normalizer = app(NovaCardProjectNormalizer::class);
$result = $normalizer->normalizeForCard($card);
expect($result['schema_version'])->toBe(3);
expect($result)->toHaveKey('canvas');
expect($result)->toHaveKey('frame');
expect($result)->toHaveKey('effects');
});
});
// ─── Creator preset tests ─────────────────────────────────────────────────────
describe('NovaCardCreatorPresetService', function (): void {
it('creates a preset for a user', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
$preset = $service->create($user, [
'name' => 'My Midnight Style',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => ['typography' => ['font_preset' => 'bold-poster']],
]);
expect($preset)->toBeInstanceOf(NovaCardCreatorPreset::class)
->and($preset->name)->toBe('My Midnight Style')
->and($preset->preset_type)->toBe(NovaCardCreatorPreset::TYPE_STYLE)
->and($preset->user_id)->toBe($user->id);
});
it('enforces per-type limit', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
for ($i = 0; $i < NovaCardCreatorPresetService::MAX_PER_TYPE; $i++) {
$service->create($user, [
'name' => "Preset $i",
'preset_type' => NovaCardCreatorPreset::TYPE_LAYOUT,
'config_json' => [],
]);
}
expect(fn () => $service->create($user, [
'name' => 'One too many',
'preset_type' => NovaCardCreatorPreset::TYPE_LAYOUT,
'config_json' => [],
]))->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class);
});
it('captures preset fields from a card project_json', function (): void {
$user = v3User();
$card = v3Card($user);
$service = app(NovaCardCreatorPresetService::class);
$preset = $service->captureFromCard($user, $card, 'My Style Capture', NovaCardCreatorPreset::TYPE_STYLE);
$config = $preset->config_json;
// Style presets capture typography
expect($config)->toHaveKey('typography');
});
it('applies a background preset to produce a project patch', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
$preset = NovaCardCreatorPreset::query()->create([
'user_id' => $user->id,
'name' => 'Cinematic BG',
'preset_type' => NovaCardCreatorPreset::TYPE_BACKGROUND,
'config_json' => [
'background' => ['type' => 'gradient', 'gradient_preset' => 'deep-cinema'],
],
'is_default' => false,
]);
$card = v3Card($user);
$patch = $service->applyToProjectPatch($preset, $card);
expect($patch)->toHaveKey('background')
->and($patch['background']['gradient_preset'])->toBe('deep-cinema');
});
it('sets a preset as the default for its type', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
$presetA = $service->create($user, ['name' => 'A', 'preset_type' => NovaCardCreatorPreset::TYPE_TYPOGRAPHY, 'config_json' => []]);
$presetB = $service->create($user, ['name' => 'B', 'preset_type' => NovaCardCreatorPreset::TYPE_TYPOGRAPHY, 'config_json' => []]);
$service->setDefault($user, $presetB->id);
expect(NovaCardCreatorPreset::query()->find($presetA->id)->is_default)->toBeFalse()
->and(NovaCardCreatorPreset::query()->find($presetB->id)->is_default)->toBeTrue();
});
});
// ─── Rising service tests ─────────────────────────────────────────────────────
describe('NovaCardRisingService', function (): void {
it('returns recently published cards with engagement', function (): void {
$user = v3User();
$rising = v3Card($user, [
'published_at' => now()->subHours(12),
'saves_count' => 30,
'likes_count' => 20,
]);
$old = v3Card($user, [
'slug' => 'old-card-' . Str::lower(Str::random(6)),
'published_at' => now()->subDays(10),
'saves_count' => 100,
]);
$service = app(NovaCardRisingService::class);
$results = $service->risingCards(20, false);
expect($results->pluck('id'))->toContain($rising->id)
->and($results->pluck('id'))->not->toContain($old->id);
});
it('invalidateCache does not throw', function (): void {
$service = app(NovaCardRisingService::class);
expect(fn () => $service->invalidateCache())->not->toThrow(\Throwable::class);
});
});
// ─── Related cards service tests ───────────────────────────────────────────────
describe('NovaCardRelatedCardsService', function (): void {
it('returns related cards for a given card', function (): void {
$user = v3User();
$source = v3Card($user, ['style_family' => 'minimal']);
$related = v3Card($user, [
'slug' => 'related-card-' . Str::lower(Str::random(6)),
'style_family' => 'minimal',
'published_at' => now()->subHours(2),
]);
$service = app(NovaCardRelatedCardsService::class);
$results = $service->related($source, 8, false);
expect($results->pluck('id'))->toContain($related->id)
->and($results->pluck('id'))->not->toContain($source->id);
});
});
// ─── Export model tests ────────────────────────────────────────────────────────
describe('NovaCardExport', function (): void {
it('isReady returns true only when status is ready', function (): void {
$export = new NovaCardExport(['status' => NovaCardExport::STATUS_READY]);
expect($export->isReady())->toBeTrue();
$pending = new NovaCardExport(['status' => NovaCardExport::STATUS_PENDING]);
expect($pending->isReady())->toBeFalse();
});
it('isExpired returns true when expires_at is in the past', function (): void {
$expired = new NovaCardExport(['expires_at' => now()->subHour()]);
expect($expired->isExpired())->toBeTrue();
$fresh = new NovaCardExport(['expires_at' => now()->addHour()]);
expect($fresh->isExpired())->toBeFalse();
});
});
// ─── API: preset routes ────────────────────────────────────────────────────────
describe('Nova Cards v3 API — presets', function (): void {
it('lists presets for authenticated user', function (): void {
$user = v3User();
NovaCardCreatorPreset::query()->create([
'user_id' => $user->id,
'name' => 'API preset',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => [],
'is_default' => false,
]);
$response = $this->actingAs($user)->getJson(route('api.cards.presets.index'));
$response->assertOk();
$response->assertJsonFragment(['name' => 'API preset']);
});
it('creates a preset via API', function (): void {
$user = v3User();
$response = $this->actingAs($user)->postJson(route('api.cards.presets.store'), [
'name' => 'API created preset',
'preset_type' => NovaCardCreatorPreset::TYPE_BACKGROUND,
'config_json' => ['background' => ['type' => 'gradient']],
]);
$response->assertCreated();
expect(NovaCardCreatorPreset::query()->where('user_id', $user->id)->count())->toBe(1);
});
it('deletes a preset via API', function (): void {
$user = v3User();
$preset = NovaCardCreatorPreset::query()->create([
'user_id' => $user->id,
'name' => 'To delete',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => [],
'is_default' => false,
]);
$response = $this->actingAs($user)->deleteJson(route('api.cards.presets.destroy', $preset->id));
$response->assertOk();
expect(NovaCardCreatorPreset::query()->find($preset->id))->toBeNull();
});
it('prevents deleting another user\'s preset', function (): void {
$owner = v3User();
$attacker = v3User();
$preset = NovaCardCreatorPreset::query()->create([
'user_id' => $owner->id,
'name' => 'Owned preset',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => [],
'is_default' => false,
]);
$response = $this->actingAs($attacker)->deleteJson(route('api.cards.presets.destroy', $preset->id));
$response->assertForbidden();
});
});
// ─── Web: rising page ────────────────────────────────────────────────────────
describe('Nova Cards v3 web — rising page', function (): void {
it('rising page returns 200', function (): void {
$this->get(route('cards.rising'))->assertOk();
});
});