optimizations
This commit is contained in:
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