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(); }); });