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

469 lines
17 KiB
PHP

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