optimizations
This commit is contained in:
393
tests/Feature/NovaCards/NovaCardAdminTest.php
Normal file
393
tests/Feature/NovaCards/NovaCardAdminTest.php
Normal 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();
|
||||
});
|
||||
468
tests/Feature/NovaCards/NovaCardDraftApiTest.php
Normal file
468
tests/Feature/NovaCards/NovaCardDraftApiTest.php
Normal 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();
|
||||
});
|
||||
621
tests/Feature/NovaCards/NovaCardPublicPagesTest.php
Normal file
621
tests/Feature/NovaCards/NovaCardPublicPagesTest.php
Normal 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();
|
||||
});
|
||||
358
tests/Feature/NovaCards/NovaCardReportingTest.php
Normal file
358
tests/Feature/NovaCards/NovaCardReportingTest.php
Normal 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"');
|
||||
});
|
||||
32
tests/Feature/NovaCards/NovaCardSeederHooksTest.php
Normal file
32
tests/Feature/NovaCards/NovaCardSeederHooksTest.php
Normal 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);
|
||||
});
|
||||
249
tests/Feature/NovaCards/NovaCardStudioPagesTest.php
Normal file
249
tests/Feature/NovaCards/NovaCardStudioPagesTest.php
Normal 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));
|
||||
});
|
||||
368
tests/Feature/NovaCards/NovaCardV2ApiTest.php
Normal file
368
tests/Feature/NovaCards/NovaCardV2ApiTest.php
Normal 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();
|
||||
});
|
||||
357
tests/Feature/NovaCards/NovaCardV3Test.php
Normal file
357
tests/Feature/NovaCards/NovaCardV3Test.php
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user