create(array_merge([ 'slug' => 'report-category-' . Str::lower(Str::random(6)), 'name' => 'Report Category', 'description' => 'Report category', 'active' => true, 'order_num' => 0, ], $attributes)); } function reportCardTemplate(array $attributes = []): NovaCardTemplate { return NovaCardTemplate::query()->create(array_merge([ 'slug' => 'report-template-' . Str::lower(Str::random(6)), 'name' => 'Report Template', 'description' => 'Report 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'], 'active' => true, 'official' => true, 'order_num' => 0, ], $attributes)); } function reportableCard(User $user, array $attributes = []): NovaCard { $category = $attributes['category'] ?? reportCardCategory(); $template = $attributes['template'] ?? reportCardTemplate(); return NovaCard::query()->create(array_merge([ 'user_id' => $user->id, 'category_id' => $category->id, 'template_id' => $template->id, 'title' => 'Reportable Card', 'slug' => 'reportable-card-' . Str::lower(Str::random(6)), 'quote_text' => 'Card that can be reported.', 'format' => NovaCard::FORMAT_SQUARE, 'project_json' => [ 'content' => ['title' => 'Reportable Card', 'quote_text' => 'Card that can be reported.'], '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' => 2, 'schema_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()->subMinutes(5), ], Arr::except($attributes, ['category', 'template']))); } it('accepts nova card, challenge, and challenge entry reports through the shared intake endpoint', function (): void { $reporter = User::factory()->create(['username' => 'reporter']); $creator = User::factory()->create(['username' => 'creator']); $card = reportableCard($creator); $challenge = NovaCardChallenge::query()->create([ 'user_id' => $creator->id, 'slug' => 'reporting-challenge', 'title' => 'Reporting Challenge', 'description' => 'Challenge description', 'prompt' => 'Challenge prompt', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, 'featured' => false, 'starts_at' => now()->subDay(), 'ends_at' => now()->addWeek(), ]); $entry = NovaCardChallengeEntry::query()->create([ 'challenge_id' => $challenge->id, 'card_id' => $card->id, 'user_id' => $creator->id, 'status' => NovaCardChallengeEntry::STATUS_SUBMITTED, 'note' => 'Challenge entry note', ]); $this->actingAs($reporter) ->postJson(route('api.reports.store'), [ 'target_type' => 'nova_card', 'target_id' => $card->id, 'reason' => 'Misleading card', ]) ->assertCreated(); $this->actingAs($reporter) ->postJson(route('api.reports.store'), [ 'target_type' => 'nova_card_challenge', 'target_id' => $challenge->id, 'reason' => 'Bad challenge brief', ]) ->assertCreated(); $this->actingAs($reporter) ->postJson(route('api.reports.store'), [ 'target_type' => 'nova_card_challenge_entry', 'target_id' => $entry->id, 'reason' => 'Spam challenge entry', 'details' => 'This entry is unrelated to the prompt.', ]) ->assertCreated(); expect(Report::query()->where('reporter_id', $reporter->id)->count())->toBe(3); }); it('accepts nova card comment reports through the shared intake endpoint', function (): void { $reporter = User::factory()->create(['username' => 'commentreporter']); $creator = User::factory()->create(['username' => 'commentreportcreator']); $card = reportableCard($creator, ['title' => 'Comment Report Card']); $comment = NovaCardComment::query()->create([ 'card_id' => $card->id, 'user_id' => $creator->id, 'body' => 'Questionable comment body.', 'rendered_body' => 'Questionable comment body.', 'status' => 'visible', ]); $this->actingAs($reporter) ->postJson(route('api.reports.store'), [ 'target_type' => 'nova_card_comment', 'target_id' => $comment->id, 'reason' => 'Abusive comment', ]) ->assertCreated(); expect(Report::query()->where('target_type', 'nova_card_comment')->where('target_id', $comment->id)->exists())->toBeTrue(); }); it('filters nova card reports in the moderation queue and allows status transitions', function (): void { $admin = User::factory()->create(['role' => 'admin']); $reporter = User::factory()->create(['username' => 'queuereporter']); $creator = User::factory()->create(['username' => 'queuecreator']); $card = reportableCard($creator, ['title' => 'Queue Card']); $challenge = NovaCardChallenge::query()->create([ 'user_id' => $creator->id, 'slug' => 'queue-challenge', 'title' => 'Queue Challenge', 'prompt' => 'Queue prompt', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, 'featured' => false, 'starts_at' => now()->subDay(), 'ends_at' => now()->addWeek(), ]); $cardReport = Report::query()->create([ 'reporter_id' => $reporter->id, 'target_type' => 'nova_card', 'target_id' => $card->id, 'reason' => 'Card spam', 'status' => 'open', ]); Report::query()->create([ 'reporter_id' => $reporter->id, 'target_type' => 'nova_card_challenge', 'target_id' => $challenge->id, 'reason' => 'Challenge abuse', 'status' => 'open', ]); Report::query()->create([ 'reporter_id' => $reporter->id, 'target_type' => 'user', 'target_id' => $creator->id, 'reason' => 'Unrelated user report', 'status' => 'open', ]); $queue = $this->actingAs($admin) ->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'open'])) ->assertOk(); expect($queue->json('meta.total'))->toBe(2) ->and(collect($queue->json('data'))->pluck('target.label')->all())->toContain('Queue Card', 'Queue Challenge'); $this->actingAs($admin) ->patchJson(route('api.admin.reports.update', ['report' => $cardReport->id]), [ 'status' => 'reviewing', ]) ->assertOk() ->assertJsonPath('report.status', 'reviewing') ->assertJsonPath('report.target.label', 'Queue Card'); $reviewing = $this->actingAs($admin) ->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'reviewing'])) ->assertOk(); expect($reviewing->json('meta.total'))->toBe(1) ->and($cardReport->fresh()->status)->toBe('reviewing'); }); it('records moderator notes and report history entries from the moderation queue', function (): void { $admin = User::factory()->create(['role' => 'admin', 'username' => 'auditadmin']); $reporter = User::factory()->create(['username' => 'auditreporter']); $creator = User::factory()->create(['username' => 'auditcreator']); $card = reportableCard($creator, ['title' => 'Audit Queue Card']); $report = Report::query()->create([ 'reporter_id' => $reporter->id, 'target_type' => 'nova_card', 'target_id' => $card->id, 'reason' => 'Needs closer review', 'status' => 'open', ]); $response = $this->actingAs($admin) ->patchJson(route('api.admin.reports.update', ['report' => $report->id]), [ 'status' => 'reviewing', 'moderator_note' => 'Escalated to card moderation while we verify the prompt source.', ]) ->assertOk(); $report->refresh(); expect($report->status)->toBe('reviewing') ->and($report->moderator_note)->toBe('Escalated to card moderation while we verify the prompt source.') ->and($report->last_moderated_by_id)->toBe($admin->id) ->and($report->historyEntries()->count())->toBe(1) ->and($response->json('report.history.0.summary'))->toContain('Status open -> reviewing') ->and($response->json('report.history.0.actor.username'))->toBe('auditadmin'); }); it('allows moderators to update the underlying nova card from a report row', function (): void { $admin = User::factory()->create(['role' => 'admin']); $reporter = User::factory()->create(['username' => 'targetreporter']); $creator = User::factory()->create(['username' => 'targetcreator']); $card = reportableCard($creator, [ 'title' => 'Flaggable Queue Card', 'moderation_status' => NovaCard::MOD_PENDING, 'project_json' => [ 'content' => ['title' => 'Flaggable Queue Card', 'quote_text' => 'Report queue card'], 'moderation' => [ 'source' => 'publish_heuristics', 'flagged' => true, 'reasons' => ['self_remix_loop'], ], ], ]); $report = Report::query()->create([ 'reporter_id' => $reporter->id, 'target_type' => 'nova_card', 'target_id' => $card->id, 'reason' => 'Suspicious engagement bait', 'status' => 'reviewing', ]); $this->actingAs($admin) ->postJson(route('api.admin.reports.moderate-target', ['report' => $report->id]), [ 'action' => 'flag_card', 'disposition' => 'rights_review_required', ]) ->assertOk() ->assertJsonPath('report.target.moderation_target.card_id', $card->id) ->assertJsonPath('report.target.moderation_target.moderation_status', NovaCard::MOD_FLAGGED) ->assertJsonPath('report.target.moderation_target.moderation_reasons.0', 'self_remix_loop') ->assertJsonPath('report.target.moderation_target.moderation_reason_labels.0', 'Self-remix loop') ->assertJsonPath('report.target.moderation_target.moderation_override.source', 'report_queue') ->assertJsonPath('report.target.moderation_target.moderation_override.disposition', 'rights_review_required') ->assertJsonPath('report.target.moderation_target.moderation_override.disposition_label', 'Rights review required') ->assertJsonPath('report.target.moderation_target.moderation_override_history.0.disposition_label', 'Rights review required') ->assertJsonPath('report.target.moderation_target.moderation_override.report_id', $report->id) ->assertJsonPath('report.history.0.action_type', 'target_moderated'); expect($card->fresh()->moderation_status)->toBe(NovaCard::MOD_FLAGGED) ->and($card->fresh()->project_json['moderation']['override']['source'] ?? null)->toBe('report_queue') ->and($card->fresh()->project_json['moderation']['override']['disposition'] ?? null)->toBe('rights_review_required') ->and($report->fresh()->historyEntries()->count())->toBe(1) ->and($report->fresh()->last_moderated_by_id)->toBe($admin->id); }); it('includes nova card comment reports in the nova cards moderation queue', function (): void { $admin = User::factory()->create(['role' => 'admin']); $reporter = User::factory()->create(['username' => 'commentqueuereporter']); $creator = User::factory()->create(['username' => 'commentqueuecreator']); $card = reportableCard($creator, ['title' => 'Queue Comment Card']); $comment = NovaCardComment::query()->create([ 'card_id' => $card->id, 'user_id' => $creator->id, 'body' => 'Queue this comment too.', 'rendered_body' => 'Queue this comment too.', 'status' => 'visible', ]); Report::query()->create([ 'reporter_id' => $reporter->id, 'target_type' => 'nova_card_comment', 'target_id' => $comment->id, 'reason' => 'Comment harassment', 'status' => 'open', ]); $queue = $this->actingAs($admin) ->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'open'])) ->assertOk(); expect(collect($queue->json('data'))->pluck('target.type')->all())->toContain('nova_card_comment'); }); it('renders challenge reporting controls for authenticated viewers', function (): void { $viewer = User::factory()->create(['username' => 'challengeviewer']); $creator = User::factory()->create(['username' => 'challengecreator']); $card = reportableCard($creator, ['title' => 'Challenge Card']); $challenge = NovaCardChallenge::query()->create([ 'user_id' => $creator->id, 'slug' => 'challenge-report-page', 'title' => 'Challenge Report Page', 'description' => 'Challenge description', 'prompt' => 'Challenge prompt', 'status' => NovaCardChallenge::STATUS_ACTIVE, 'official' => true, 'featured' => true, 'starts_at' => now()->subDay(), 'ends_at' => now()->addWeek(), ]); NovaCardChallengeEntry::query()->create([ 'challenge_id' => $challenge->id, 'card_id' => $card->id, 'user_id' => $creator->id, 'status' => NovaCardChallengeEntry::STATUS_SUBMITTED, ]); $response = $this->actingAs($viewer) ->get(route('cards.challenges.show', ['slug' => $challenge->slug])) ->assertOk(); expect($response->getContent()) ->toContain('data-report-target-type="nova_card_challenge"') ->toContain('data-report-target-type="nova_card_challenge_entry"'); });