Files
2026-04-22 10:43:31 +09:00

787 lines
27 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
require_once __DIR__.'/../../ModuleTestCase.php';
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
use Modules\Sirsoft\Board\Enums\ReportStatus;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Report;
use Modules\Sirsoft\Board\Models\ReportLog;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* Report 기능 통합 테스트
*/
class ReportTest extends ModuleTestCase
{
private User $admin;
private User $reporter;
private User $author;
private Board $board;
protected function setUp(): void
{
parent::setUp();
config(['telescope.enabled' => false]);
App::setLocale('ko');
// DDL implicit commit으로 이전 테스트 User가 잔류 → Faker email 충돌 방지
DB::table('boards_report_logs')->delete();
DB::table('boards_reports')->delete();
DB::table('users')->where('is_super', false)->delete();
// 테스트 사용자 생성
$this->admin = User::factory()->create();
$this->reporter = User::factory()->create();
$this->author = User::factory()->create();
// admin 사용자에게 권한 부여 (ModuleTestCase가 admin 역할을 생성하므로 firstOrCreate)
$viewPermission = Permission::firstOrCreate(
['identifier' => 'sirsoft-board.reports.view'],
['name' => ['ko' => '신고 조회', 'en' => 'View Reports'], 'type' => 'admin']
);
$managePermission = Permission::firstOrCreate(
['identifier' => 'sirsoft-board.reports.manage'],
['name' => ['ko' => '신고 관리', 'en' => 'Manage Reports'], 'type' => 'admin']
);
$adminRole = Role::where('identifier', 'admin')->first();
$adminRole->permissions()->syncWithoutDetaching([$viewPermission->id, $managePermission->id]);
$this->admin->roles()->attach($adminRole->id);
// 테스트 게시판 생성 (updateOrCreate: DDL commit으로 이전 board가 남아있을 수 있으므로 속성 갱신 보장)
$this->board = Board::updateOrCreate(
['slug' => 'report-test-board'],
[
'name' => ['ko' => '신고 테스트 게시판', 'en' => 'Report Test Board'],
'slug' => 'report-test-board',
'type' => 'list',
'per_page' => 20,
'per_page_mobile' => 10,
'order_by' => 'created_at',
'order_direction' => 'DESC',
'secret_mode' => 'disabled',
'use_comment' => true,
'use_reply' => false,
'use_file_upload' => false,
'use_report' => true,
'blocked_keywords' => [],
'notify_admin_on_post' => false,
'notify_author_on_comment' => false,
]
);
// 이전 테스트 잔여 데이터 정리 (DatabaseTransactions 비활성 환경 호환)
// API가 전체 신고를 반환하므로 board_id 필터 없이 전체 삭제
DB::table('boards_report_logs')->delete();
DB::table('boards_reports')->delete();
DB::table('board_comments')->where('board_id', $this->board->id)->delete();
DB::table('board_posts')->where('board_id', $this->board->id)->delete();
}
/**
* 신고 목록 조회 테스트
*/
public function test_can_fetch_reports_list(): void
{
// Given: 신고 데이터 생성
Report::factory()->count(5)->create([
'board_id' => $this->board->id,
]);
// When: 목록 조회 API 호출
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports');
// Then: 성공 응답 및 데이터 확인
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'data' => [
'*' => [
'id',
'board_id',
'target_type',
'target_id',
'reporter',
'status',
'reason_type',
'created_at',
],
],
'statistics',
'pagination' => [
'current_page',
'per_page',
'total',
],
],
]);
$this->assertEquals(5, $response->json('data.pagination.total'));
}
/**
* 검색 필터 테스트 - 상태별
*/
public function test_can_filter_reports_by_status(): void
{
// Given: 다양한 상태의 신고 생성
Report::factory()->create([
'board_id' => $this->board->id,
'status' => ReportStatus::Pending,
]);
Report::factory()->create([
'board_id' => $this->board->id,
'status' => ReportStatus::Review,
]);
// When: pending 상태만 필터링
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports?status=pending');
// Then: pending 상태만 조회
$response->assertStatus(200);
$this->assertEquals(1, $response->json('data.pagination.total'));
}
/**
* 검색 필터 테스트 - 타입별
*/
public function test_can_filter_reports_by_type(): void
{
// Given: post와 comment 신고 생성
Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
]);
Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'comment',
]);
// When: post 타입만 필터링
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports?target_type=post');
// Then: post 타입만 조회
$response->assertStatus(200);
$this->assertEquals(1, $response->json('data.pagination.total'));
}
/**
* 신고 상세 조회 테스트
*/
public function test_can_fetch_report_detail(): void
{
// Given: 신고 케이스 + ReportLog 생성 (1케이스 구조)
$report = Report::factory()->create([
'board_id' => $this->board->id,
'author_id' => $this->author->id,
]);
$log = ReportLog::factory()->create([
'report_id' => $report->id,
'reporter_id' => $this->reporter->id,
'reason_type' => ReportReasonType::Spam,
]);
// When: 상세 조회 API 호출
$response = $this->actingAs($this->admin)
->getJson("/api/modules/sirsoft-board/admin/reports/{$report->id}");
// Then: 성공 응답 및 상세 데이터 확인 (reporters 키: boards_report_logs 기반)
$response->assertStatus(200)
->assertJson([
'success' => true,
'data' => [
'id' => $report->id,
'status' => $report->status->value,
'reporters' => [
[
'id' => $log->id,
'reason_type' => ReportReasonType::Spam->value,
],
],
],
]);
}
/**
* 신고 상태 변경 테스트 (단일)
*/
public function test_can_update_report_status(): void
{
// Given: pending 상태의 신고
$report = Report::factory()->create([
'board_id' => $this->board->id,
'status' => ReportStatus::Pending,
]);
// When: review 상태로 변경
$response = $this->actingAs($this->admin)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'review',
'process_note' => '확인 중입니다.',
]);
// Then: 상태 변경 성공
$response->assertStatus(200)
->assertJson([
'success' => true,
]);
$this->assertDatabaseHas('boards_reports', [
'id' => $report->id,
'status' => 'review',
'processed_by' => $this->admin->id,
]);
}
/**
* 대량 상태 변경 테스트
*/
public function test_can_bulk_update_report_status(): void
{
// Given: 여러 개의 pending 신고
$reports = Report::factory()->count(3)->create([
'board_id' => $this->board->id,
'status' => ReportStatus::Pending,
]);
$ids = $reports->pluck('id')->toArray();
// When: 대량 상태 변경
$response = $this->actingAs($this->admin)
->patchJson('/api/modules/sirsoft-board/admin/reports/bulk-status', [
'ids' => $ids,
'status' => 'rejected',
'process_note' => '일괄 반려 처리',
]);
// Then: 모든 신고 상태 변경
$response->assertStatus(200);
foreach ($ids as $id) {
$this->assertDatabaseHas('boards_reports', [
'id' => $id,
'status' => 'rejected',
]);
}
}
/**
* 신고 삭제 테스트
*/
public function test_can_delete_report(): void
{
// Given: 신고 데이터
$report = Report::factory()->create([
'board_id' => $this->board->id,
'author_id' => $this->author->id,
]);
// When: 삭제 요청
$response = $this->actingAs($this->admin)
->deleteJson("/api/modules/sirsoft-board/admin/reports/{$report->id}");
// Then: 소프트 삭제 성공
$response->assertStatus(200);
$this->assertSoftDeleted('boards_reports', [
'id' => $report->id,
]);
}
/**
* 권한 체크 테스트 - 조회 권한 없음
*/
public function test_unauthorized_user_cannot_view_reports(): void
{
// Given: 권한 없는 일반 사용자
$user = User::factory()->create();
// When: 목록 조회 시도
$response = $this->actingAs($user)
->getJson('/api/modules/sirsoft-board/admin/reports');
// Then: 권한 없음 응답
$response->assertStatus(403);
}
/**
* 권한 체크 테스트 - 상태 변경 권한 없음
*/
public function test_unauthorized_user_cannot_update_status(): void
{
// Given: 권한 없는 일반 사용자
$user = User::factory()->create();
$report = Report::factory()->create([
'board_id' => $this->board->id,
]);
// When: 상태 변경 시도
$response = $this->actingAs($user)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'review',
]);
// Then: 권한 없음 응답
$response->assertStatus(403);
}
/**
* 중복 신고 방지 테스트
*/
public function test_prevents_duplicate_reports(): void
{
// Given: 게시글 작성
$postId = $this->createTestPost();
// 첫 번째 신고: 케이스 + 로그 생성 (reporter_id는 logs에 저장)
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'author_id' => $this->author->id,
]);
ReportLog::factory()->create([
'report_id' => $report->id,
'reporter_id' => $this->reporter->id,
]);
// When: 같은 사용자가 같은 게시글 재신고 시도
$response = $this->actingAs($this->reporter)
->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/reports", [
'reason_type' => 'spam',
'reason_detail' => '중복 신고 시도',
]);
// Then: 중복 신고 에러 (409 Conflict)
$response->assertStatus(409)
->assertJsonPath('success', false);
}
/**
* 페이지네이션 제한 테스트 (10~20개)
*/
public function test_pagination_limit_between_10_and_20(): void
{
// Given: 30개 신고 생성
Report::factory()->count(30)->create([
'board_id' => $this->board->id,
'author_id' => $this->author->id,
]);
// When: per_page=5 요청 (최소값 미만)
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports?per_page=5');
// Then: 최소값 10으로 조정됨
$response->assertStatus(200);
$this->assertEquals(10, $response->json('data.pagination.per_page'));
// When: per_page=25 요청 (최대값 초과)
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports?per_page=25');
// Then: 최대값 20으로 조정됨
$response->assertStatus(200);
$this->assertEquals(20, $response->json('data.pagination.per_page'));
}
/**
* 검증 실패 테스트 - 상태 변경 시 잘못된 status 값
*/
public function test_validation_fails_with_invalid_status(): void
{
// Given: 신고 데이터
$report = Report::factory()->create([
'board_id' => $this->board->id,
]);
// When: 잘못된 status 값 전송
$response = $this->actingAs($this->admin)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'invalid_status',
]);
// Then: 검증 실패
$response->assertStatus(422)
->assertJsonValidationErrors(['status']);
}
/**
* 검증 실패 테스트 - process_note 길이 초과
*/
public function test_validation_fails_with_too_long_process_note(): void
{
// Given: 신고 데이터
$report = Report::factory()->create([
'board_id' => $this->board->id,
]);
// When: 1001자 process_note 전송
$response = $this->actingAs($this->admin)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'review',
'process_note' => str_repeat('a', 1001),
]);
// Then: 검증 실패
$response->assertStatus(422)
->assertJsonValidationErrors(['process_note']);
}
/**
* 스냅샷 데이터 저장 테스트
*/
public function test_saves_snapshot_data_on_report_creation(): void
{
// Given: 게시글 작성
$postId = $this->createTestPost();
// When: 1케이스 구조 — 케이스(boards_reports) + 로그(boards_report_logs) 생성
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
]);
$log = ReportLog::factory()->create([
'report_id' => $report->id,
'reporter_id' => $this->reporter->id,
'snapshot' => [
'board_name' => $this->board->name,
'title' => '테스트 게시글',
'content' => '테스트 내용',
'content_mode' => 'text',
'author_name' => $this->author->name,
],
]);
// Then: 스냅샷 데이터는 ReportLog에 저장됨
$log->refresh();
$this->assertEquals($this->board->name, $log->snapshot['board_name']);
$this->assertEquals('테스트 게시글', $log->snapshot['title']);
}
/**
* 신고 상태 변경 후 게시글 상태 동기화 테스트 (suspended)
*/
public function test_report_status_update_blinds_post_content(): void
{
// Given: 게시글과 신고 생성
$postId = $this->createTestPost();
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'status' => ReportStatus::Pending,
]);
// When: suspended 상태로 변경
$response = $this->actingAs($this->admin)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'suspended',
'process_note' => '블라인드 처리합니다.',
]);
// Then: 성공 응답
$response->assertStatus(200);
// 게시글이 blinded 상태로 변경되었는지 확인 (게시중단은 삭제가 아니므로 deleted_at은 null)
$post = DB::table('board_posts')->find($postId);
$this->assertEquals('blinded', $post->status);
$this->assertEquals('report', $post->trigger_type);
$this->assertNull($post->deleted_at);
}
/**
* 신고 상태 변경 후 게시글 상태 동기화 테스트 (deleted)
*/
public function test_report_status_update_deletes_post_content(): void
{
// Given: 게시글과 신고 생성
$postId = $this->createTestPost();
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'status' => ReportStatus::Pending,
]);
// When: deleted 상태로 변경
$response = $this->actingAs($this->admin)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'deleted',
'process_note' => '영구 삭제합니다.',
]);
// Then: 성공 응답
$response->assertStatus(200);
// 게시글이 deleted 상태로 변경되었는지 확인
$post = DB::table('board_posts')->find($postId);
$this->assertEquals('deleted', $post->status);
$this->assertEquals('report', $post->trigger_type);
$this->assertNotNull($post->deleted_at);
}
/**
* 신고 반려 후 게시글 복구 테스트
*/
public function test_report_rejection_restores_post_content(): void
{
// Given: 블라인드된 게시글과 suspended 상태의 신고
$postId = $this->createTestPost();
// 게시글을 블라인드 상태로 변경 (게시중단은 삭제가 아니므로 deleted_at은 null)
DB::table('board_posts')
->where('id', $postId)
->update([
'status' => 'blinded',
'trigger_type' => 'report',
]);
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'status' => ReportStatus::Suspended,
]);
// When: rejected 상태로 변경 (신고 반려)
$response = $this->actingAs($this->admin)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'rejected',
'process_note' => '신고 반려합니다.',
]);
// Then: 성공 응답
$response->assertStatus(200);
// 게시글이 published 상태로 복구되었는지 확인
$post = DB::table('board_posts')->find($postId);
$this->assertEquals('published', $post->status);
$this->assertEquals('report', $post->trigger_type);
$this->assertNull($post->deleted_at);
}
/**
* 댓글 신고 목록에서 board.title은 스냅샷 기반(null)이고,
* 상세 조회에서 상위 게시글 제목이 포함되는지 테스트
*/
public function test_comment_report_includes_post_title_in_board_title(): void
{
// Given: 게시글 생성
$postId = $this->createTestPost();
// 댓글 생성
$commentId = $this->createTestComment($postId);
// 댓글에 대한 신고 생성 (1케이스 구조: snapshot은 ReportLog에 저장)
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'comment',
'target_id' => $commentId,
'author_id' => $this->author->id,
]);
ReportLog::factory()->create([
'report_id' => $report->id,
'reporter_id' => $this->reporter->id,
'snapshot' => [
'board_name' => $this->board->name,
'title' => null,
'content' => '테스트 댓글 내용',
'content_mode' => 'text',
'author_name' => $this->author->name,
],
]);
// When: 목록 조회 API 호출
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports');
// Then: 목록에서 댓글 신고의 board.title은 스냅샷 기반이므로 null (N+1 방지 설계)
$response->assertStatus(200);
$reportData = collect($response->json('data.data'))->firstWhere('id', $report->id);
$this->assertNotNull($reportData, '신고 데이터가 응답에 포함되어야 합니다');
$this->assertNull($reportData['board']['title'], '목록에서 댓글 신고의 title은 스냅샷 기반(null)');
// 단일 테이블(board_comments)이므로 별도 DROP 불필요 (RefreshDatabase가 처리)
}
/**
* 신고 목록 API 응답에서 author/reporter가 uuid를 반환하는지 검증
*/
public function test_report_list_returns_uuid_for_author_and_reporter(): void
{
// Given: 게시글 신고 생성
$postId = $this->createTestPost();
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'author_id' => $this->author->id,
]);
ReportLog::factory()->create([
'report_id' => $report->id,
'reporter_id' => $this->reporter->id,
'snapshot' => [
'board_name' => $this->board->name,
'title' => '테스트 게시글',
'content' => '테스트 내용',
'content_mode' => 'text',
'author_name' => $this->author->name,
],
]);
// When: 목록 조회 API 호출
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports');
$response->assertStatus(200);
$reportData = collect($response->json('data.data'))->firstWhere('id', $report->id);
$this->assertNotNull($reportData);
// Then: author에 uuid가 있고 id 키는 없어야 함
$this->assertArrayHasKey('uuid', $reportData['author']);
$this->assertArrayNotHasKey('id', $reportData['author']);
$this->assertEquals($this->author->uuid, $reportData['author']['uuid']);
// Then: reporter에 uuid가 있고 id 키는 없어야 함
$this->assertArrayHasKey('uuid', $reportData['reporter']);
$this->assertArrayNotHasKey('id', $reportData['reporter']);
$this->assertEquals($this->reporter->uuid, $reportData['reporter']['uuid']);
}
/**
* 신고 목록 API 응답에서 processor가 uuid를 반환하는지 검증
*
* processor는 ReportResource(목록 API)에만 포함됨 (ReportDetailResource 제외)
*/
public function test_report_list_returns_uuid_for_processor(): void
{
// Given: 신고 생성 후 상태 변경 API로 처리자(processor) 설정
$postId = $this->createTestPost();
$report = Report::factory()->create([
'board_id' => $this->board->id,
'target_type' => 'post',
'target_id' => $postId,
'author_id' => $this->author->id,
]);
ReportLog::factory()->create([
'report_id' => $report->id,
'reporter_id' => $this->reporter->id,
'snapshot' => [
'board_name' => $this->board->name,
'title' => '테스트 게시글',
'content' => '테스트 내용',
'content_mode' => 'text',
'author_name' => $this->author->name,
],
]);
// 상태 변경 API 호출 → processed_by = admin->id 설정됨
$this->actingAs($this->admin)
->patchJson("/api/modules/sirsoft-board/admin/reports/{$report->id}/status", [
'status' => 'review',
'process_note' => 'UUID 검증 테스트',
])
->assertStatus(200);
// When: 목록 API 호출 (processor는 ReportResource에서 반환)
$response = $this->actingAs($this->admin)
->getJson('/api/modules/sirsoft-board/admin/reports');
$response->assertStatus(200);
$reportData = collect($response->json('data.data'))->firstWhere('id', $report->id);
$this->assertNotNull($reportData);
// Then: processor에 uuid가 있고 id 키는 없어야 함
$this->assertNotNull($reportData['processor']);
$this->assertArrayHasKey('uuid', $reportData['processor']);
$this->assertArrayNotHasKey('id', $reportData['processor']);
$this->assertEquals($this->admin->uuid, $reportData['processor']['uuid']);
}
/**
* 테스트용 게시글 생성 헬퍼
*/
private function createTestPost(): int
{
return DB::table('board_posts')->insertGetId([
'board_id' => $this->board->id,
'title' => '테스트 게시글',
'content' => '테스트 내용',
'user_id' => $this->author->id,
'author_name' => $this->author->name,
'ip_address' => '127.0.0.1',
'is_notice' => false,
'is_secret' => false,
'status' => 'published',
'trigger_type' => 'admin',
'view_count' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* 테스트용 댓글 생성 헬퍼
*
* @param int $postId 게시글 ID
* @return int 생성된 댓글 ID
*/
private function createTestComment(int $postId): int
{
return DB::table('board_comments')->insertGetId([
'board_id' => $this->board->id,
'post_id' => $postId,
'user_id' => $this->author->id,
'author_name' => $this->author->name,
'content' => '테스트 댓글 내용',
'is_secret' => false,
'status' => 'published',
'trigger_type' => 'admin',
'depth' => 0,
'ip_address' => '127.0.0.1',
'created_at' => now(),
'updated_at' => now(),
]);
}
}