where('slug', $this->boardSlug)->delete(); DB::table('boards')->where('slug', 'nav-test-inactive')->delete(); $this->board = Board::factory()->create([ 'slug' => $this->boardSlug, 'is_active' => true, 'order_by' => 'created_at', 'order_direction' => 'DESC', 'secret_mode' => SecretMode::Disabled->value, ]); $this->ensureGuestCanReadPosts(); } /** * navigation 라우트는 permission:user,posts.read 로 보호됩니다. * guest 역할에 해당 권한을 부여해 401 차단을 우회합니다. */ private function ensureGuestCanReadPosts(): void { $identifier = 'sirsoft-board.'.$this->boardSlug.'.posts.read'; $permId = DB::table('permissions')->where('identifier', $identifier)->value('id'); if (! $permId) { $permId = DB::table('permissions')->insertGetId([ 'identifier' => $identifier, 'name' => json_encode(['ko' => 'navigation 테스트 read', 'en' => 'nav-test read']), 'type' => 'user', 'extension_type' => 'module', 'extension_identifier' => 'sirsoft-board', 'resource_route_key' => 'post', 'owner_key' => 'user_id', 'created_at' => now(), 'updated_at' => now(), ]); } $guestRoleId = DB::table('roles')->where('identifier', 'guest')->value('id'); if (! $guestRoleId) { return; } DB::table('role_permissions')->updateOrInsert( ['role_id' => $guestRoleId, 'permission_id' => $permId], ['scope_type' => null, 'granted_at' => now(), 'created_at' => now(), 'updated_at' => now()] ); } protected function tearDown(): void { DB::table('board_posts')->where('board_id', $this->board->id)->delete(); DB::table('boards')->whereIn('slug', [$this->boardSlug, 'nav-test-inactive'])->delete(); parent::tearDown(); } /** * 존재하지 않는 글 ID → 200 + 빈 값 (500 금지) */ public function test_returns_empty_when_post_not_found(): void { $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/999999/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => ['prev' => null, 'next' => null], ]); } /** * 공지글 → 200 + 빈 값 (네비게이션 미제공) * * @scenario post_kind=notice * * @effects navigation_null_for_notice */ public function test_returns_empty_for_notice_post(): void { $noticeId = $this->insertPost([ 'is_notice' => true, 'title' => '공지', ]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$noticeId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => ['prev' => null, 'next' => null], ]); } /** * 일반글 양옆 글 없음 → 200 + 빈 값 */ public function test_returns_empty_when_no_adjacent_posts(): void { $postId = $this->insertPost(['title' => '혼자']); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$postId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => ['prev' => null, 'next' => null], ]); } /** * 일반글 + 이전/다음 글 존재 → 정상 반환 (created_at DESC 정렬) * * created_at DESC 정렬에서 prev는 created_at이 더 나중인 글(newer), * next는 created_at이 더 이전인 글(older). */ public function test_returns_adjacent_posts_when_available(): void { $olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]); $currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]); $newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => [ 'prev' => ['id' => $newerId], // DESC 에서 prev = created_at 더 나중 'next' => ['id' => $olderId], // DESC 에서 next = created_at 더 이전 ], ]); } /** * 비밀글(secret_mode=always)에서도 navigation 은 is_secret 필터를 적용하지 않고 * 목록과 동일 범위로 prev/next 를 실제로 반환해야 합니다. * * getAdjacentPosts 쿼리에 is_secret 필터가 없음을 실증적으로 검증합니다. * * @scenario post_kind=original * * @effects navigation_secret_included */ public function test_secret_always_board_returns_real_prev_next(): void { DB::table('boards')->where('id', $this->board->id)->update([ 'secret_mode' => SecretMode::Always->value, ]); $olderId = $this->insertPost([ 'is_secret' => true, 'title' => '비밀 이전글', 'created_at' => now()->subMinutes(30), ]); $currentId = $this->insertPost([ 'is_secret' => true, 'title' => '비밀 현재글', 'created_at' => now()->subMinutes(20), ]); $newerId = $this->insertPost([ 'is_secret' => true, 'title' => '비밀 최신글', 'created_at' => now()->subMinutes(10), ]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => [ 'prev' => ['id' => $newerId], // DESC 'next' => ['id' => $olderId], ], ]); } /** * 핵심 회귀 방지: Controller 가 PostService::getPost 를 호출하지 않음을 실증합니다. * * beta.2 의 500 경로는 getPost 내부의 PermissionHelper::checkScopeAccess 가 false 를 * 반환하면서 AccessDeniedHttpException 을 throw 할 때 발생했습니다. 수정 코드가 * getPost 호출을 실제로 제거했는지 spy 로 검증합니다. */ public function test_controller_does_not_invoke_getpost(): void { $postId = $this->insertPost(['title' => 'getPost 미호출 검증']); $postService = $this->spy(PostService::class); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$postId}/navigation"); $this->assertNotSame(500, $response->status()); $postService->shouldNotHaveReceived('getPost'); } /** * 핵심 회귀 방지: getAdjacentPosts 가 예외를 던져도 Controller 는 500 이 아닌 * 200 + 빈 값 을 반환하고 Log::warning 을 한 번 호출해야 합니다. */ public function test_controller_degrades_gracefully_when_getadjacent_throws(): void { $postId = $this->insertPost(['title' => '예외 유도']); Log::spy(); $postService = $this->mock(PostService::class); $postService->shouldReceive('isPostNotice')->once()->andReturn(false); // 원글(parent_id null) 메타 → 답글 가드 통과 후 getAdjacentPosts 진입 $postService->shouldReceive('getPostNavigationMeta')->once()->andReturn(['category' => null, 'parent_id' => null]); $postService->shouldReceive('getAdjacentPosts')->once()->andThrow( new \RuntimeException('boom') ); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$postId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => ['prev' => null, 'next' => null], ]); Log::shouldHaveReceived('warning') ->once() ->withArgs(function ($message, $context) use ($postId) { return str_contains($message, 'Post navigation') && ($context['post_id'] ?? null) === $postId && ($context['slug'] ?? null) === $this->boardSlug; }); } /** * 정상 경로(예외 없음) 에서는 Log::warning 이 호출되지 않아야 합니다. */ public function test_controller_does_not_log_warning_on_success(): void { $this->insertPost(['title' => '이전', 'created_at' => now()->subMinutes(20)]); $currentId = $this->insertPost(['title' => '현재', 'created_at' => now()->subMinutes(15)]); $this->insertPost(['title' => '최신', 'created_at' => now()->subMinutes(10)]); Log::spy(); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation"); $response->assertStatus(200); Log::shouldNotHaveReceived('warning'); } /** * [A-1] 존재하지 않는 게시판은 404 또는 permission 단계의 401 을 반환해야 합니다 (500 금지). */ public function test_returns_error_for_nonexistent_board(): void { $response = $this->getJson('/api/modules/sirsoft-board/boards/non-existent-board/posts/1/navigation'); $this->assertNotSame(500, $response->status(), '존재하지 않는 게시판에서 500 이 아니어야 합니다.'); $this->assertContains($response->status(), [401, 403, 404], 'permission 또는 board-not-found 경로로 처리되어야 합니다.'); } /** * [A-2] 비활성 게시판은 404 또는 permission 단계의 401 을 반환해야 합니다 (500 금지). */ public function test_returns_error_for_inactive_board(): void { Board::factory()->create([ 'slug' => 'nav-test-inactive', 'is_active' => false, 'order_by' => 'created_at', 'order_direction' => 'DESC', 'secret_mode' => SecretMode::Disabled->value, ]); $response = $this->getJson('/api/modules/sirsoft-board/boards/nav-test-inactive/posts/1/navigation'); $this->assertNotSame(500, $response->status(), '비활성 게시판에서 500 이 아니어야 합니다.'); $this->assertContains($response->status(), [401, 403, 404]); } /** * [B-1] order_direction=ASC 에서 prev 는 created_at 더 이전 글, next 는 더 나중 글이어야 합니다. */ public function test_asc_order_flips_prev_next_direction(): void { DB::table('boards')->where('id', $this->board->id)->update(['order_direction' => 'ASC']); $olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]); $currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]); $newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => [ 'prev' => ['id' => $olderId], 'next' => ['id' => $newerId], ], ]); } /** * [D] 일반글 사이에 공지글이 끼어 있어도 navigation 은 공지를 건너뛰어야 합니다. */ public function test_notice_posts_excluded_from_navigation(): void { $olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]); $this->insertPost(['title' => '공지', 'is_notice' => true, 'created_at' => now()->subMinutes(25)]); $currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]); $this->insertPost(['title' => '공지2', 'is_notice' => true, 'created_at' => now()->subMinutes(15)]); $newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => [ 'prev' => ['id' => $newerId], // created_at DESC 'next' => ['id' => $olderId], ], ]); $response->assertJsonMissingPath('data.prev.is_notice'); } /** * [G] published 가 아닌(블라인드 등) 글은 navigation 에 포함되면 안 됩니다. * * @scenario post_kind=original * * @effects navigation_blinded_excluded */ public function test_non_published_posts_excluded_from_navigation(): void { $olderId = $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]); $this->insertPost([ 'title' => '블라인드글', 'status' => PostStatus::Blinded->value, 'created_at' => now()->subMinutes(25), ]); $currentId = $this->insertPost(['title' => '현재글', 'created_at' => now()->subMinutes(20)]); $this->insertPost([ 'title' => '삭제상태글', 'status' => PostStatus::Deleted->value, 'created_at' => now()->subMinutes(15), ]); $newerId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$currentId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => [ 'prev' => ['id' => $newerId], 'next' => ['id' => $olderId], ], ]); } /** * [J-1] 가장 오래된 글(DESC 정렬에서 next 방향 끝)은 next 가 null 이어야 합니다. * * @scenario post_kind=original * * @effects navigation_boundary_null */ public function test_oldest_post_has_no_next(): void { $oldestId = $this->insertPost(['title' => '가장 오래된 글', 'created_at' => now()->subMinutes(30)]); $this->insertPost(['title' => '중간글', 'created_at' => now()->subMinutes(20)]); $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$oldestId}/navigation"); $response->assertStatus(200); $data = $response->json('data'); $this->assertNotNull($data['prev'], 'DESC 정렬 첫글 기준 prev 는 더 이전(older) 이 없으므로 next 가 null 이어야 합니다.'); $this->assertNull($data['next'], 'DESC 정렬에서 가장 오래된 글의 next 는 null 이어야 합니다.'); } /** * [J-2] 가장 최신 글(DESC 정렬에서 prev 방향 끝)은 prev 가 null 이어야 합니다. */ public function test_newest_post_has_no_prev(): void { $this->insertPost(['title' => '이전글', 'created_at' => now()->subMinutes(30)]); $this->insertPost(['title' => '중간글', 'created_at' => now()->subMinutes(20)]); $newestId = $this->insertPost(['title' => '최신글', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$newestId}/navigation"); $response->assertStatus(200); $data = $response->json('data'); $this->assertNull($data['prev'], 'DESC 정렬에서 가장 최신 글의 prev 는 null 이어야 합니다.'); $this->assertNotNull($data['next']); } /** * [47-1] 카테고리가 섞인 게시판에서 navigation 은 현재 글과 동일 카테고리 글만 * 이전/다음으로 반환해야 합니다. (다른 카테고리 글은 건너뜀) * * @scenario post_kind=original * * @effects navigation_limited_to_same_category */ public function test_navigation_applies_current_post_category(): void { // created_at DESC 정렬. 카테고리를 번갈아 배치하여 '동일 카테고리만' 을 검증. $freeOlder = $this->insertPost(['title' => '자유-이전', 'category' => '자유', 'created_at' => now()->subMinutes(50)]); $this->insertPost(['title' => '질문-사이1', 'category' => '질문', 'created_at' => now()->subMinutes(40)]); $current = $this->insertPost(['title' => '자유-현재', 'category' => '자유', 'created_at' => now()->subMinutes(30)]); $this->insertPost(['title' => '질문-사이2', 'category' => '질문', 'created_at' => now()->subMinutes(20)]); $freeNewer = $this->insertPost(['title' => '자유-최신', 'category' => '자유', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$current}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => [ 'prev' => ['id' => $freeNewer], // DESC: 더 최신 자유글 'next' => ['id' => $freeOlder], // DESC: 더 이전 자유글 ], ]); } /** * [47-1] 미분류(category=null) 글은 카테고리 필터 없이 전체 원글을 순회해야 합니다. * * @scenario post_kind=original * * @effects navigation_uncategorized_traverses_all */ public function test_navigation_for_uncategorized_post_traverses_all(): void { $older = $this->insertPost(['title' => '이전(질문)', 'category' => '질문', 'created_at' => now()->subMinutes(30)]); $current = $this->insertPost(['title' => '현재(미분류)', 'category' => null, 'created_at' => now()->subMinutes(20)]); $newer = $this->insertPost(['title' => '최신(자유)', 'category' => '자유', 'created_at' => now()->subMinutes(10)]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$current}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => [ 'prev' => ['id' => $newer], 'next' => ['id' => $older], ], ]); } /** * [47-4] 답글(parent_id != null) 상세의 navigation 은 prev/next 모두 null 이어야 합니다. * * 답글은 원글 후보 쿼리(whereNull parent_id)에서 제외되므로, 답글 상세에서 이전/다음 * 버튼이 뜨면 자기 스레드와 무관한 원글로 이동하는 깨진 동작이 발생합니다. * * @scenario post_kind=reply * * @effects navigation_null_for_reply */ public function test_navigation_returns_null_for_reply(): void { // 원글 3개 (정상 순회 가능한 상태) $this->insertPost(['title' => '원글1', 'created_at' => now()->subMinutes(40)]); $parentId = $this->insertPost(['title' => '원글2', 'created_at' => now()->subMinutes(30)]); $this->insertPost(['title' => '원글3', 'created_at' => now()->subMinutes(20)]); // 원글2 에 대한 답글 $replyId = $this->insertPost([ 'title' => '답글', 'parent_id' => $parentId, 'created_at' => now()->subMinutes(10), ]); $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->boardSlug}/posts/{$replyId}/navigation"); $response->assertStatus(200); $response->assertJson([ 'data' => ['prev' => null, 'next' => null], ]); } /** * board_posts 레코드를 직접 INSERT 합니다. * * @param array $overrides 오버라이드할 속성 * @return int 생성된 post id */ private function insertPost(array $overrides = []): int { $defaults = [ 'board_id' => $this->board->id, 'title' => '테스트글', 'content' => '본문', 'content_mode' => 'text', 'author_name' => '작성자', 'ip_address' => '127.0.0.1', 'is_notice' => false, 'is_secret' => false, 'status' => PostStatus::Published->value, 'view_count' => 0, 'created_at' => now(), 'updated_at' => now(), ]; return DB::table('board_posts')->insertGetId(array_merge($defaults, $overrides)); } }