Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php
T
chym1217 facbd98b14 fix(board): 게시글 반응 개수 표시 결함 수정 + 반응 버튼 UX 개선
Chrome MCP 검수 중 발견한 결함 수정 및 요청 반영.

- 게시글 상세 응답의 reaction_counts 가 배열로 붕괴되어 개수가 0으로
 표시되던 문제 수정 (PostResource → stdClass 로 JSON 객체 보장) + 회귀 테스트
- 반응 버튼을 수정·삭제 버튼과 같은 줄에 배치
- 반응 UX 개선: 클릭 즉시 하이라이트 반영 + 개수는 응답값으로 갱신(전체
 재조회 제거) + 처리 중 로딩·중복클릭 차단 + 실패 시 롤백·안내
- board.md 상세 응답 필드 + basic CHANGELOG + 시나리오 매니페스트 갱신
2026-08-07 17:06:49 +09:00

260 lines
9.6 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature\User;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Database\Seeders\BoardReactionTypeSeeder;
use Modules\Sirsoft\Board\Models\ReactionType;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 반응 API Feature 테스트.
*
* 등록/전환/해제, reaction_counts 증감 정합성(전환 포함), use_reaction off·비활성 유형
* 차단, 본인 글 차단, 비로그인 차단을 종단 검증한다 (이슈 #525 §10 테스트 범위).
*
* @scenario case=guest_react_blocked
* @effects guest_react_returns_401
*/
class ReactionApiTest extends BoardTestCase
{
private int $likeId;
private int $dislikeId;
protected function setUp(): void
{
parent::setUp();
// FK(restrictOnDelete) 순서: 이력 먼저 정리 후 유형 삭제 (타 테스트 잔여 데이터 대비)
DB::table('board_reactions')->delete();
ReactionType::query()->delete();
$this->seed(BoardReactionTypeSeeder::class);
$this->likeId = ReactionType::query()->where('code', 'like')->value('id');
$this->dislikeId = ReactionType::query()->where('code', 'dislike')->value('id');
$this->updateBoardSettings([
'use_reaction' => true,
'active_reaction_types' => ['like', 'dislike'],
]);
}
/**
* 반응 API 엔드포인트 URL 을 조립합니다.
*/
private function reactUrl(int $postId): string
{
return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/react";
}
/**
* 반응 등록 → 카운트 +1, my_reaction_type_id 반영.
*/
public function test_register_reaction(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$response = $this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]);
$response->assertOk()
->assertJsonPath('data.action', 'add')
->assertJsonPath('data.my_reaction_type_id', $this->likeId)
->assertJsonPath("data.reaction_counts.{$this->likeId}", 1);
$this->assertDatabaseHas('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
'reaction_type_id' => $this->likeId,
]);
}
/**
* 다른 유형으로 전환 → 이전 -1·신규 +1 (증감 정합성).
*/
public function test_switch_reaction_adjusts_counts(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
$response = $this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId]);
$response->assertOk()
->assertJsonPath('data.action', 'change')
->assertJsonPath('data.my_reaction_type_id', $this->dislikeId)
->assertJsonPath("data.reaction_counts.{$this->likeId}", 0)
->assertJsonPath("data.reaction_counts.{$this->dislikeId}", 1);
}
/**
* 같은 유형 재클릭 → 해제, 카운트 -1, my_reaction_type_id null.
*/
public function test_remove_reaction_on_same_type(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
$response = $this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]);
$response->assertOk()
->assertJsonPath('data.action', 'remove')
->assertJsonPath('data.my_reaction_type_id', null)
->assertJsonPath("data.reaction_counts.{$this->likeId}", 0);
$this->assertDatabaseMissing('board_reactions', [
'user_id' => $reactor->id,
'target_id' => $postId,
]);
}
/**
* 비로그인 요청은 401 로 차단된다 (확정 07).
*/
public function test_guest_cannot_react(): void
{
$author = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertUnauthorized();
}
/**
* use_reaction 이 꺼진 게시판은 422 로 차단된다.
*/
public function test_react_blocked_when_use_reaction_off(): void
{
$this->updateBoardSettings(['use_reaction' => false]);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertStatus(422);
}
/**
* 게시판이 켜지 않은(비활성) 유형은 검증 단계(422)에서 차단된다.
*/
public function test_react_blocked_for_inactive_type(): void
{
$this->updateBoardSettings(['active_reaction_types' => ['like']]);
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId])
->assertStatus(422);
}
/**
* 본인 글에는 반응할 수 없다 (422, 확정 08).
*/
public function test_cannot_react_to_own_post(): void
{
$author = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
$this->actingAs($author, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertStatus(422);
}
/**
* 다른 게시판 소속이 아닌(존재하지 않는) 게시글은 404 로 차단된다.
*/
public function test_react_to_missing_post_returns_404(): void
{
$reactor = $this->createUser();
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl(999999), ['reaction_type_id' => $this->likeId])
->assertNotFound();
}
/**
* 게시글 상세 응답의 reaction_counts 가 유형 ID 키를 보존한다 (JSON 객체, 배열 재인덱싱 금지).
*
* 회귀: PostResource 의 reaction_counts 키가 정수 [1,2] 라 JsonResource::resolve() 가
* list 로 오인해 [count1, count2] 로 재인덱싱하면, 프론트가 reaction_counts[유형ID] 로
* 읽을 때 엉뚱한 인덱스를 읽어 개수가 항상 0/어긋나게 표시된다. 키는 항상 유형 ID 여야 한다.
*/
public function test_post_detail_reaction_counts_preserves_type_id_keys(): void
{
// 상세 조회는 posts.read 권한이 필요하다
$this->grantUserRolePermissions(['posts.read', 'posts.write']);
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $this->createUser()->id]);
// 추천 1회 등록
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
// 게시글 상세 조회 — reaction_counts 는 유형 ID 키로 개수를 담아야 한다
$detail = $this->actingAs($reactor, 'sanctum')
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
$detail->assertOk()
->assertJsonPath("data.reaction_counts.{$this->likeId}", 1)
->assertJsonPath("data.reaction_counts.{$this->dislikeId}", 0)
->assertJsonPath('data.my_reaction_type_id', $this->likeId);
// 키가 유형 ID 로 보존되는지 직접 확인 (0-기반 재인덱싱 아님)
$counts = $detail->json('data.reaction_counts');
$this->assertArrayHasKey((string) $this->likeId, $counts);
$this->assertArrayNotHasKey('0', $counts);
}
/**
* 반응 등록/전환/해제 시 활동 로그가 기록된다 (after_react 훅 → 리스너).
*
* @scenario case=react_logs_activity
* @effects react_logs_add_change_remove_activity
*/
public function test_react_writes_activity_log(): void
{
$author = $this->createUser();
$reactor = $this->createUser();
$postId = $this->createTestPost(['user_id' => $author->id]);
// 등록 → reaction.add 로그
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId])
->assertOk();
$this->assertDatabaseHas('activity_logs', ['action' => 'reaction.add']);
// 전환 → reaction.change 로그
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId])
->assertOk();
$this->assertDatabaseHas('activity_logs', ['action' => 'reaction.change']);
// 해제 → reaction.remove 로그
$this->actingAs($reactor, 'sanctum')
->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId])
->assertOk();
$this->assertDatabaseHas('activity_logs', ['action' => 'reaction.remove']);
}
}