Files
SkinbaseNova/tests/Feature/NovaCards/NovaCardV3Test.php
2026-03-28 19:15:39 +01:00

358 lines
15 KiB
PHP

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