optimizations
This commit is contained in:
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();
|
||||
});
|
||||
Reference in New Issue
Block a user