test(board): 게시글 반응 시나리오 매니페스트·E2E·CHANGELOG

5단계 마무리 — 기능 동작 확인 후 마감.

- tests/scenarios/board-reaction.yaml: 단일 case 축 18조합 + effects 22개,
 각 테스트에 @scenario/@effects 마킹 (test-scenario-coverage 통과)
- board-reaction-flow.spec.ts: 등록→전환→해제 E2E (describe.skip, 토큰 환경 해제)
 + reactionToken fixture
- 부수 테스트: react 활동 로그 기록, 새 게시판 반응 기본값 시드(확정 13)
- board/basic CHANGELOG Added 에 반응 기능 기재 (working 버전 누적, +2 아님)

ja 언어팩 동기화는 ANTHROPIC_API_KEY( 환경) 필요 — 후속 진행.
This commit is contained in:
chym1217
2026-08-07 17:04:51 +09:00
parent 2ba0c2ba2d
commit a4632eb47e
13 changed files with 315 additions and 0 deletions
@@ -13,6 +13,7 @@
### Added
- 게시글에 추천·비추천 반응을 남길 수 있는 기능을 추가했습니다. 로그인한 회원은 게시글 상세에서 반응을 남기거나 다른 반응으로 바꾸거나 취소할 수 있고, 각 반응의 개수가 항상 함께 표시됩니다. 본인이 쓴 글에는 반응할 수 없습니다. 게시판 관리 설정에서 반응 사용 여부와 사용할 반응 유형(추천·비추천)을 게시판별로 켜고 끌 수 있으며, 새 게시판을 만들 때 적용될 기본값도 환경설정에서 지정합니다.
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
@@ -24,6 +24,8 @@ const settingsJson = JSON.stringify(settingsTab);
const formJson = JSON.stringify(formTab);
describe('환경설정 기본값 탭 — 반응 사용 토글 + 유형 체크박스', () => {
// @scenario case=settings_toggle_and_checkbox_persist
// @effects settings_toggle_persists_use_reaction
it('use_reaction 토글이 basic_defaults.use_reaction 에 바인딩된다', () => {
expect(/"name":\s*"basic_defaults\.use_reaction"/.test(settingsJson)).toBe(true);
});
@@ -37,6 +39,8 @@ describe('환경설정 기본값 탭 — 반응 사용 토글 + 유형 체크박
expect(/"item_var":\s*"reactionType"/.test(settingsJson)).toBe(true);
});
// @scenario case=settings_toggle_and_checkbox_persist
// @effects settings_checkbox_persists_active_reaction_types
it('체크박스가 active_reaction_types(code 배열) 를 include/exclude 로 토글한다', () => {
// 체크 상태: code 포함 여부
expect(
@@ -146,6 +146,37 @@ class BoardManagementTest extends ModuleTestCase
$this->assertTrue($response->json('data.is_active'));
}
/**
* 게시판 생성 시 반응 기본값이 모듈 환경설정(basic_defaults)에서 시드된다 (이슈 #525 확정 13).
*
* @scenario case=new_board_seeds_reaction_defaults
* @effects new_board_seeds_reaction_defaults_from_settings
*/
public function test_board_created_seeds_reaction_defaults_from_settings(): void
{
// Given: 반응 관련 필드를 지정하지 않은 게시판 데이터 (기본값 시드 기대)
$slug = 'test-'.substr(md5(microtime()), 0, 8);
$data = [
'name' => ['ko' => '테스트 게시판', 'en' => 'Test Board'],
'slug' => $slug,
'type' => 'basic',
'show_view_count' => true,
'use_report' => false,
'board_manager_ids' => [$this->adminUser->uuid],
];
// When: 게시판 생성 API 호출
$response = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-board/admin/boards', $data);
// Then: 반응 기본값(use_reaction=true, active_reaction_types=["like"])이 시드됨
$response->assertStatus(201);
$board = \Modules\Sirsoft\Board\Models\Board::where('slug', $slug)->firstOrFail();
$this->assertTrue($board->use_reaction);
$this->assertContains('like', $board->active_reaction_types ?? []);
}
/**
* is_active false로 게시판 생성 테스트
*/
@@ -48,6 +48,9 @@ class ReactionTypeApiTest extends ModuleTestCase
/**
* en 로케일에서는 영어 라벨을 반환한다.
*
* @scenario case=reaction_type_label_localized
* @effects reaction_type_name_localized
*/
public function test_reaction_type_name_localized_to_en(): void
{
@@ -62,6 +65,9 @@ class ReactionTypeApiTest extends ModuleTestCase
/**
* 미입력 언어는 사이트 기본 언어(fallback)로 대체 노출된다 (확정 15).
*
* @scenario case=reaction_type_label_falls_back
* @effects missing_locale_falls_back_to_default
*/
public function test_missing_locale_falls_back(): void
{
@@ -12,6 +12,9 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
*
* 등록/전환/해제, reaction_counts 증감 정합성(전환 포함), use_reaction off·비활성 유형
* 차단, 본인 글 차단, 비로그인 차단을 종단 검증한다 (이슈 #525 §10 테스트 범위).
*
* @scenario case=guest_react_blocked
* @effects guest_react_returns_401
*/
class ReactionApiTest extends BoardTestCase
{
@@ -187,4 +190,35 @@ class ReactionApiTest extends BoardTestCase
->postJson($this->reactUrl(999999), ['reaction_type_id' => $this->likeId])
->assertNotFound();
}
/**
* 반응 등록/전환/해제 시 활동 로그가 기록된다 (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']);
}
}
@@ -27,6 +27,8 @@ type BoardAuthFixtures = {
boardManageToken: string;
/** 게시판 조회 + 첨부 다운로드 권한 토큰 (#413-58b 행위자 기록 검증용) */
attachmentDownloadToken: string;
/** 게시판/게시글 조회 권한 토큰 (반응 등록/전환/해제 흐름 검증용, 반응은 로그인만 요구) */
reactionToken: string;
};
export const test = base.extend<BoardAuthFixtures>({
@@ -69,6 +71,15 @@ export const test = base.extend<BoardAuthFixtures>({
),
);
},
reactionToken: async ({}, use) => {
// 반응은 별도 권한 없이 로그인(auth:sanctum)만 요구. 게시글 조회 권한만 발급.
await use(
issueToken(
'sirsoft-board.boards.read',
'sirsoft-board.notice.posts.read',
),
);
},
});
export { authenticatePage };
@@ -0,0 +1,98 @@
/**
* 게시판 반응(추천/비추천) 사용자 흐름 — 등록 → 전환 → 해제 (이슈 #525).
*
* 게시글 상세에서 반응 버튼을 눌러 등록하고, 다른 유형으로 전환하고, 같은 유형을 재클릭해
* 해제하는 흐름을 브라우저에서 확인한다. 각 클릭 후 게시글 데이터소스를 재조회(refetchDataSource)
* 하므로, 서버 응답의 my_reaction_type_id·reaction_counts 가 버튼 하이라이트와 개수에 반영된다.
*
* 단위/레이아웃 테스트(ReactionServiceTest, ReactionApiTest, board-reaction-buttons.test.tsx)는
* 등록/전환/해제 판정·카운트 증감·렌더 분기를 검증하므로, 이 spec 은 실제 클릭 → API 호출 →
* 재조회 → 화면 갱신의 종단 흐름을 담당한다.
*
* @scenario surface=detail_register_switch_remove
* @effects detail_register_then_switch_then_remove_updates_ui,register_inserts_row_and_increments_count,switch_updates_row_and_adjusts_both_counts,remove_deletes_row_and_decrements_count
*
* 활성화 절차: PlaywrightIssueToken 발급 + 반응 유형이 켜진 공개 게시판/게시글 시드가 가능한
* 환경에서 test.describe.skip → test.describe. SLUG/POST_ID 는 시드에 맞춰 조정.
*/
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
const SLUG = 'notice';
const POST_ID = 1;
const POST_PATH = `/board/${SLUG}/${POST_ID}`;
const REACT_API = `**/api/modules/sirsoft-board/boards/${SLUG}/posts/${POST_ID}/react`;
// 유형 ID (시드 순서상 like=1, dislike=2 가정 — 시드에 맞춰 조정)
const LIKE_ID = 1;
const DISLIKE_ID = 2;
test.describe.skip('게시판 반응 등록→전환→해제 흐름 (#525)', () => {
// @scenario surface=detail_register_switch_remove
// @effects register_inserts_row_and_increments_count, switch_updates_row_and_adjusts_both_counts, remove_deletes_row_and_decrements_count
test('추천 등록 → 비추천 전환 → 비추천 해제 시 버튼 하이라이트·개수가 갱신된다', async ({
page,
reactionToken,
}) => {
await authenticatePage(page, reactionToken);
// react API 응답을 흐름 단계별로 제어 (등록/전환/해제)
const steps = [
{ action: 'add', my: LIKE_ID, counts: { [LIKE_ID]: 1, [DISLIKE_ID]: 0 } },
{ action: 'change', my: DISLIKE_ID, counts: { [LIKE_ID]: 0, [DISLIKE_ID]: 1 } },
{ action: 'remove', my: null, counts: { [LIKE_ID]: 0, [DISLIKE_ID]: 0 } },
];
let step = 0;
let reactBody: Record<string, unknown> | null = null;
await page.route(REACT_API, async (route) => {
reactBody = route.request().postDataJSON() as Record<string, unknown>;
const s = steps[Math.min(step, steps.length - 1)];
step += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
message: 'ok',
data: { action: s.action, my_reaction_type_id: s.my, reaction_counts: s.counts },
}),
});
});
await page.goto(POST_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const likeButton = page.getByRole('button', { name: /추천|Recommend/ }).first();
const dislikeButton = page.getByRole('button', { name: /비추천|Not Recommend/ }).first();
// 1) 추천 등록 → count +1, reaction_type_id = like
await likeButton.click();
await expect.poll(() => reactBody?.reaction_type_id, { timeout: 5_000 }).toBe(LIKE_ID);
// 2) 비추천 전환 → like -1 · dislike +1
await dislikeButton.click();
await expect.poll(() => reactBody?.reaction_type_id, { timeout: 5_000 }).toBe(DISLIKE_ID);
// 3) 비추천 재클릭 → 해제 (같은 유형 재요청)
await dislikeButton.click();
await expect.poll(() => reactBody?.reaction_type_id, { timeout: 5_000 }).toBe(DISLIKE_ID);
// 세 번의 react 호출이 모두 발생 (등록/전환/해제)
expect(step).toBe(3);
});
// @scenario surface=detail_register_switch_remove
// @effects guest_react_returns_401
test('비로그인 상태에서 반응 클릭 시 로그인 안내 후 로그인 페이지로 이동한다', async ({ page }) => {
// 인증 없이 진입 (토큰 미주입)
await page.goto(POST_PATH);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const likeButton = page.getByRole('button', { name: /추천|Recommend/ }).first();
await likeButton.click();
// 비로그인 → 로그인 페이지로 이동 (redirect 파라미터로 원글 경로 전달)
await expect.poll(() => page.url(), { timeout: 5_000 }).toContain('/login');
expect(page.url()).toContain('redirect');
});
});
@@ -25,6 +25,9 @@ class BoardReactionTypeSeederTest extends ModuleTestCase
/**
* 시더가 추천/비추천 2건을 정확한 값으로 등록한다.
*
* @scenario case=reaction_type_label_localized
* @effects reaction_type_name_localized
*/
public function test_seeder_creates_like_and_dislike_types(): void
{
@@ -43,6 +43,9 @@ class ReactionRepositoryTest extends BoardTestCase
/**
* upsert 는 없으면 INSERT, 있으면 유형만 UPDATE 한다 (사용자당 1행 유지).
*
* @scenario case=register_first_like
* @effects register_inserts_row_and_increments_count
*/
public function test_upsert_inserts_then_updates_single_row(): void
{
@@ -46,6 +46,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* 반응이 없던 게시글에 반응하면 신규 등록되고 카운트가 +1 된다.
*
* @scenario case=register_first_like
* @effects register_inserts_row_and_increments_count
*/
public function test_react_adds_new_reaction_and_increments_count(): void
{
@@ -69,6 +72,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* 다른 유형으로 전환하면 이전 유형 -1·신규 유형 +1 이 동시 반영된다 (단일 트랜잭션).
*
* @scenario case=switch_like_to_dislike
* @effects switch_updates_row_and_adjusts_both_counts, switch_count_atomic_in_single_transaction
*/
public function test_react_switches_type_and_adjusts_both_counts(): void
{
@@ -94,6 +100,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* 같은 유형을 재클릭하면 해제되어 이력 행이 삭제되고 카운트가 -1 된다.
*
* @scenario case=remove_same_type
* @effects remove_deletes_row_and_decrements_count
*/
public function test_react_same_type_removes_reaction_and_decrements_count(): void
{
@@ -116,6 +125,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* 카운트는 0 미만으로 내려가지 않는다 (해제 시 클램프).
*
* @scenario case=count_never_negative_on_over_remove
* @effects reaction_count_never_negative
*/
public function test_reaction_count_never_goes_below_zero(): void
{
@@ -131,6 +143,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* use_reaction 이 꺼진 게시판은 반응이 차단된다.
*
* @scenario case=use_reaction_off_react_blocked
* @effects use_reaction_off_react_returns_422
*/
public function test_react_blocked_when_use_reaction_off(): void
{
@@ -146,6 +161,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* 게시판이 켜지 않은(비활성) 유형은 차단된다.
*
* @scenario case=inactive_type_react_blocked
* @effects inactive_type_react_returns_422
*/
public function test_react_blocked_for_inactive_type_on_board(): void
{
@@ -161,6 +179,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* 본인 글에는 반응할 수 없다.
*
* @scenario case=self_post_react_blocked
* @effects self_post_react_returns_422
*/
public function test_react_blocked_on_own_post(): void
{
@@ -173,6 +194,9 @@ class ReactionServiceTest extends BoardTestCase
/**
* 다른 게시판 소속 게시글 ID 로는 반응할 수 없다 (스코프 검증).
*
* @scenario case=post_not_in_board
* @effects post_not_in_board_returns_404
*/
public function test_react_blocked_for_post_not_in_board(): void
{
@@ -0,0 +1,89 @@
feature: 게시판 반응(추천/비추천)
description: |
이슈 #525 — 게시글에 반응(추천/비추천)을 남기는 기능. 로그인 회원이 게시글에 대해
게시판이 켠 반응 유형 중 하나를 선택해 남긴다. 공개 리액션이라 개수를 항상 노출하고,
본인 글 셀프 반응 제한·로그인 필수 등 어뷰징 방지 조건이 붙는다.
핵심 동작 (확정):
- 반응 유형은 DB 테이블(board_reaction_types)로 관리, 초기 시드 추천(like)/비추천(dislike)
- 글당 반응 1개 — 같은 유형 재클릭=해제(DELETE), 다른 유형=전환(UPDATE, 이전 -1·신규 +1)
- 카운트는 board_posts.reaction_counts JSON(키=유형 ID), 증감은 이력 쓰기와 단일 트랜잭션
- 게시판별 use_reaction on/off + active_reaction_types(code 배열) 로 노출 유형 결정
- 활성 유형이 0개면 반응 영역 자체가 미렌더 (use_reaction on 이어도)
- 비로그인 차단(401), 본인 글 차단(422), 비활성 유형 차단(422), 타 게시판 게시글 404
- 다국어 미입력 언어는 사이트 기본 언어로 폴백
# axis 의미:
# case 는 (기존 반응 상태 · 요청 유형 · 게시판 설정 · 사용자 상태) 의 의미 있는 조합을
# 한 축으로 열거한다. 독립 축을 곱하면 무의미한 조합(예: locale × guard)이 폭증하므로
# 기획서 확정 시나리오만 case 로 둔다 (board-conditional-validation.yaml 관례).
axes:
case:
- register_first_like
- switch_like_to_dislike
- remove_same_type
- count_never_negative_on_over_remove
- guest_react_blocked
- self_post_react_blocked
- inactive_type_react_blocked
- use_reaction_off_react_blocked
- post_not_in_board
- detail_shows_active_types_even_zero
- detail_hidden_when_no_active_types
- detail_hidden_when_use_reaction_off
- author_buttons_disabled
- settings_toggle_and_checkbox_persist
- new_board_seeds_reaction_defaults
- reaction_type_label_localized
- reaction_type_label_falls_back
- react_logs_activity
effects:
# 등록/전환/해제 + 카운트 증감 정합성
- register_inserts_row_and_increments_count
- switch_updates_row_and_adjusts_both_counts
- remove_deletes_row_and_decrements_count
- reaction_count_never_negative
- switch_count_atomic_in_single_transaction
# 어뷰징/상태 차단
- guest_react_returns_401
- self_post_react_returns_422
- inactive_type_react_returns_422
- use_reaction_off_react_returns_422
- post_not_in_board_returns_404
# 화면 렌더 (확정 09·11)
- active_types_always_shown_even_zero_count
- reaction_area_hidden_when_no_active_types
- reaction_area_hidden_when_use_reaction_off
- author_sees_buttons_disabled
- my_reaction_type_highlighted
# 설정 (확정 02·13)
- settings_toggle_persists_use_reaction
- settings_checkbox_persists_active_reaction_types
- new_board_seeds_reaction_defaults_from_settings
# 다국어 (확정 15)
- reaction_type_name_localized
- missing_locale_falls_back_to_default
# 활동 로그
- react_logs_add_change_remove_activity
# E2E 흐름
- detail_register_then_switch_then_remove_updates_ui
test_files:
- modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php
- modules/_bundled/sirsoft-board/tests/Unit/Repositories/ReactionRepositoryTest.php
- modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/BoardReactionTypeSeederTest.php
- modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php
- modules/_bundled/sirsoft-board/tests/Feature/Admin/ReactionTypeApiTest.php
- modules/_bundled/sirsoft-board/tests/Feature/Admin/BoardManagementTest.php
- templates/_bundled/sirsoft-basic/__tests__/layouts/board-reaction-buttons.test.tsx
- modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-reaction-settings.test.tsx
- modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-reaction-flow.spec.ts
validation:
audit_rule: frontend-change-requires-e2e
note: |
입력 조합 전수(case)는 백엔드/프론트/E2E 테스트가 @scenario/@effects 마킹으로 커버한다.
브라우저 수준의 등록→전환→해제 사용자 흐름은 board-reaction-flow.spec.ts 가 담당한다
(PlaywrightIssueToken 발급 가능 환경에서 describe.skip 해제).
@@ -12,6 +12,7 @@
- 주문서의 무통장입금 영역에 현금영수증 신청 폼이 표시될 수 있도록 확장 영역을 마련했습니다. 신청 폼 자체는 쇼핑몰 기능이 제공합니다.
- 주문 상세의 결제 정보 영역에 현금영수증 발급 내역이 표시될 수 있도록 확장 영역을 마련했습니다.
- 주문서에서 결제 플러그인이 등록한 결제수단(예: "가상계좌 (토스페이먼츠)")을 선택해 주문할 수 있습니다. 선택한 결제수단은 주문 시 대응하는 기본 결제수단으로 전송되어, 결제창 호출과 이후 주문 처리가 정상적으로 이어집니다.
- 게시글 상세 화면에 추천·비추천 반응 버튼을 추가했습니다. 로그인한 회원은 버튼을 눌러 반응을 남기거나, 다른 반응으로 바꾸거나, 같은 버튼을 다시 눌러 취소할 수 있으며 각 반응의 개수가 함께 표시됩니다. 본인이 쓴 글에서는 버튼이 눌리지 않고, 로그인하지 않은 경우 안내 후 로그인 화면으로 이동합니다. (게시판에서 반응 기능을 켠 경우에만 표시됩니다.)
- 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다.
### Changed
@@ -41,7 +41,13 @@ function findByComment(root: unknown, commentText: string): Node[] {
}
describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', () => {
// @scenario case=detail_hidden_when_no_active_types
// @effects reaction_area_hidden_when_no_active_types
it('반응 영역은 use_reaction + reaction_type_options 존재 시에만 노출된다 (확정 11)', () => {
// 이 if 는 use_reaction 과 reaction_type_options 둘 다에 의존하므로
// reaction off / 유형 0개 두 케이스 모두 이 조건으로 미노출된다.
// @scenario case=detail_hidden_when_use_reaction_off
// @effects reaction_area_hidden_when_use_reaction_off
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
expect(areas.length).toBe(1);
@@ -52,6 +58,8 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
expect(area.if as string).toContain('length > 0');
});
// @scenario case=detail_shows_active_types_even_zero
// @effects active_types_always_shown_even_zero_count
it('반응 유형을 reaction_type_options 로 iteration 한다', () => {
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
const iterations = collectNodes(areas[0], (n) => n.iteration !== undefined);
@@ -62,6 +70,8 @@ describe('이슈 #525 — 게시글 상세 반응 버튼 (basic/show.json)', ()
expect(iter.item_var).toBe('reactionType');
});
// @scenario case=author_buttons_disabled
// @effects author_sees_buttons_disabled, active_types_always_shown_even_zero_count, my_reaction_type_highlighted
it('유형별 버튼은 본인 글이면 disabled 이고 개수를 바인딩한다 (확정 08·09)', () => {
const areas = findByComment(basicShow, '게시글 반응(추천/비추천) 영역');
const buttons = collectNodes(areas[0], (n) => n.name === 'Button');