Chrome MCP 검수 중 발견한 결함 수정 및 요청 반영. - 게시글 상세 응답의 reaction_counts 가 배열로 붕괴되어 개수가 0으로 표시되던 문제 수정 (PostResource → stdClass 로 JSON 객체 보장) + 회귀 테스트 - 반응 버튼을 수정·삭제 버튼과 같은 줄에 배치 - 반응 UX 개선: 클릭 즉시 하이라이트 반영 + 개수는 응답값으로 갱신(전체 재조회 제거) + 처리 중 로딩·중복클릭 차단 + 실패 시 롤백·안내 - board.md 상세 응답 필드 + basic CHANGELOG + 시나리오 매니페스트 갱신
260 lines
9.6 KiB
PHP
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']);
|
|
}
|
|
}
|