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