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

358 lines
15 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardComment;
use App\Models\NovaCardTemplate;
use App\Models\Report;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
function reportCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->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"');
});