optimizations

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

View File

@@ -0,0 +1,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();
});