Files
Gnuboard7/modules/_bundled/sirsoft-page/tests/Unit/Services/PageServiceTest.php
T
chym1217 9d7bccdfb9 chore(page): 코드·테스트·라우트 주석 정리 및 CHANGELOG 날짜 정정
코드/테스트 주석에 남아 있던 내부 이슈번호(-14, -15 등)를
규정에 따라 제거하고, 첨부 라우트 주석의 오해 소지 표현을 정정한다.
1.0.1 릴리스 날짜를 2026-07-06 으로 맞춘다.

- Listener/커맨드 주석, Feature/Unit/레이아웃/E2E 테스트 주석
- 시나리오 매니페스트 설명 (audit:allow 지시어는 보존)
- 첨부 라우트: admin 그룹의 오해성 "단일화" 주석 제거,
 공개 pages 그룹 각 라우트에 게시판과 동일한 방식·사유를 간결히 명시
 (미리보기=공개 이미지, 다운로드=발행 상태 조건부 접근 제어)
- CHANGELOG [1.0.1] 날짜를 2026-07-06 으로 정정
 (page 모듈 / basic·admin_basic 템플릿 / 대응 ja 언어팩 3종)
- 로직·동작 변경 없음
2026-07-06 09:37:07 +09:00

633 lines
21 KiB
PHP

<?php
namespace Modules\Sirsoft\Page\Tests\Unit\Services;
use App\Enums\PermissionType;
use App\Enums\ScopeType;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Services\PageService;
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* PageService 단위 테스트
*
* 페이지 생성/수정/삭제/발행/버전 관리 비즈니스 로직을 검증합니다.
*/
class PageServiceTest extends ModuleTestCase
{
private PageService $service;
private User $adminUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = $this->createAdminUser([]);
// Unit 테스트에서는 HTTP 요청 없이 Auth::id()를 사용하므로
// web guard와 sanctum guard 모두에 사용자를 설정
Auth::setUser($this->adminUser);
$this->actingAs($this->adminUser);
$this->service = app(PageService::class);
}
// ─── createPage ────────────────────────────────────
/**
* 페이지를 생성할 수 있는지 확인
*/
public function test_create_page_returns_page_model(): void
{
$data = [
'slug' => 'test-svc-create',
'title' => ['ko' => '서비스 테스트', 'en' => 'Service Test'],
'content' => ['ko' => '본문', 'en' => 'Content'],
'content_mode' => 'html',
];
$page = $this->service->createPage($data);
$this->assertInstanceOf(Page::class, $page);
$this->assertEquals('test-svc-create', $page->slug);
$this->assertEquals(1, $page->current_version);
}
/**
* 페이지 생성 시 버전 1 스냅샷이 자동 생성되는지 확인
*/
public function test_create_page_creates_version_one(): void
{
$page = $this->service->createPage([
'slug' => 'test-svc-version',
'title' => ['ko' => '버전 테스트', 'en' => 'Version'],
'content' => ['ko' => '본문', 'en' => 'Body'],
'content_mode' => 'html',
]);
$versions = PageVersion::where('page_id', $page->id)->get();
$this->assertCount(1, $versions);
$this->assertEquals(1, $versions->first()->version);
}
/**
* 발행 상태로 생성 시 published_at이 설정되는지 확인
*/
public function test_create_published_page_sets_published_at(): void
{
$page = $this->service->createPage([
'slug' => 'test-svc-pub',
'title' => ['ko' => '발행', 'en' => 'Published'],
'content' => ['ko' => '본문', 'en' => 'Body'],
'content_mode' => 'html',
'published' => true,
]);
$this->assertTrue($page->published);
$this->assertNotNull($page->published_at);
}
// ─── updatePage ────────────────────────────────────
/**
* 페이지를 수정할 수 있는지 확인
*/
public function test_update_page_changes_title(): void
{
$page = $this->createTestPage('test-svc-update');
$updated = $this->service->updatePage($page, [
'title' => ['ko' => '수정됨', 'en' => 'Updated'],
]);
$this->assertEquals('수정됨', $updated->title['ko']);
}
/**
* 수정 시 버전 번호가 증가하는지 확인
*/
public function test_update_page_increments_version(): void
{
$page = $this->createTestPage('test-svc-ver-inc');
$originalVersion = $page->current_version;
$updated = $this->service->updatePage($page, [
'title' => ['ko' => '버전 증가', 'en' => 'Version Up'],
]);
$this->assertEquals($originalVersion + 1, $updated->current_version);
}
/**
* 수정 시 새 버전 스냅샷이 생성되는지 확인
*/
public function test_update_page_creates_new_version_snapshot(): void
{
$page = $this->createTestPage('test-svc-snap');
$this->service->updatePage($page, [
'title' => ['ko' => '스냅샷', 'en' => 'Snapshot'],
]);
$versionCount = PageVersion::where('page_id', $page->id)->count();
$this->assertEquals(2, $versionCount); // 생성 시 1 + 수정 시 1
}
// ─── deletePage ────────────────────────────────────
/**
* 페이지를 물리 삭제할 수 있는지 확인
*/
public function test_delete_page_hard_deletes(): void
{
$page = $this->createTestPage('test-svc-delete');
$result = $this->service->deletePage($page);
$this->assertTrue($result);
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
}
// ─── changePublishStatus ───────────────────────────
/**
* 발행 상태를 변경할 수 있는지 확인
*/
public function test_change_publish_status_to_published(): void
{
$page = $this->createTestPage('test-svc-publish');
$published = $this->service->changePublishStatus($page, true);
$this->assertTrue($published->published);
$this->assertNotNull($published->published_at);
}
/**
* 미발행 상태로 변경할 수 있는지 확인
*/
public function test_change_publish_status_to_unpublished(): void
{
$page = Page::factory()->published()->create([
'slug' => 'test-svc-unpub',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$unpublished = $this->service->changePublishStatus($page, false);
$this->assertFalse($unpublished->published);
}
// ─── bulkChangePublishStatus ───────────────────────
/**
* 여러 페이지를 일괄 발행할 수 있는지 확인
*/
public function test_bulk_change_publish_status(): void
{
$pages = Page::factory()->count(3)->create([
'published' => false,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$count = $this->service->bulkChangePublishStatus(
$pages->pluck('id')->toArray(),
true
);
$this->assertEquals(3, $count);
// 일괄 발행 후 published/published_at 가 실제 갱신되는지 확인
foreach ($pages as $page) {
$this->assertDatabaseHas('pages', [
'id' => $page->id,
'published' => true,
]);
}
}
/**
* 일괄 발행 시 after_publish 훅이 페이지 수만큼 발화되는지 확인 (회귀)
*
* 일괄변경 경로가 after_publish 훅을 발화하지 않으면 활동로그가 기록되지 않는다.
* 단일 발행(changePublishStatus)과 동일하게 페이지별(per-item) 발화를 보장한다.
*/
public function test_bulk_change_publish_status_fires_after_publish_hook_per_page(): void
{
$pages = Page::factory()->count(3)->create([
'published' => false,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$firedPageIds = [];
$firedPublishedFlags = [];
HookManager::addAction(
'sirsoft-page.page.after_publish',
function (Page $page, bool $published) use (&$firedPageIds, &$firedPublishedFlags) {
$firedPageIds[] = $page->id;
$firedPublishedFlags[] = $published;
}
);
$this->service->bulkChangePublishStatus($pages->pluck('id')->toArray(), true);
// 페이지 수만큼 발화 + 각 페이지 id 가 1회씩
$this->assertCount(3, $firedPageIds);
$this->assertEqualsCanonicalizing(
$pages->pluck('id')->toArray(),
$firedPageIds
);
// 발행 인자가 모두 true
$this->assertSame([true, true, true], $firedPublishedFlags);
}
/**
* 일괄 미발행 시 after_publish 훅이 published=false 로 발화되는지 확인 (회귀)
*/
public function test_bulk_change_publish_status_fires_unpublish_hook_per_page(): void
{
$pages = Page::factory()->published()->count(3)->create([
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$firedPublishedFlags = [];
HookManager::addAction(
'sirsoft-page.page.after_publish',
function (Page $page, bool $published) use (&$firedPublishedFlags) {
$firedPublishedFlags[] = $published;
}
);
$this->service->bulkChangePublishStatus($pages->pluck('id')->toArray(), false);
$this->assertSame([false, false, false], $firedPublishedFlags);
}
/**
* 일괄 처리 도중 한 건이라도 실패하면 전체 롤백되는지 확인 (회귀)
*
* all-or-nothing: 존재하지 않는 id 가 섞이면 정상 페이지도 발행되지 않아야 한다.
*/
public function test_bulk_change_publish_status_rolls_back_on_failure(): void
{
$pages = Page::factory()->count(2)->create([
'published' => false,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$ids = $pages->pluck('id')->toArray();
$ids[] = 999999; // 존재하지 않는 페이지 id
try {
$this->service->bulkChangePublishStatus($ids, true);
$this->fail('존재하지 않는 페이지 id 포함 시 예외가 발생해야 한다.');
} catch (\Throwable $e) {
// 예외 발생 기대
}
// 정상 페이지 2건도 발행되지 않아야 함 (전체 롤백)
foreach ($pages as $page) {
$this->assertDatabaseHas('pages', [
'id' => $page->id,
'published' => false,
]);
}
}
// ─── restoreVersion ────────────────────────────────
/**
* 특정 버전으로 복원할 수 있는지 확인
*/
public function test_restore_version_reverts_content(): void
{
$page = $this->createTestPage('test-svc-restore');
$originalTitle = $page->title;
// 수정하여 버전 2 생성
$page = $this->service->updatePage($page, [
'title' => ['ko' => '변경된 제목', 'en' => 'Changed Title'],
]);
// 버전 1 가져오기
$version1 = PageVersion::where('page_id', $page->id)
->where('version', 1)
->first();
// 버전 1로 복원
$restored = $this->service->restoreVersion($page, $version1->id);
$this->assertEquals($originalTitle['ko'], $restored->title['ko']);
$this->assertEquals(3, $restored->current_version); // 복원 후 버전 증가
}
// ─── slugExists ────────────────────────────────────
/**
* 슬러그 존재 여부를 확인할 수 있는지 검증
*/
public function test_slug_exists_returns_true_for_existing_slug(): void
{
$this->createTestPage('test-svc-slug-exist');
$this->assertTrue($this->service->slugExists('test-svc-slug-exist'));
}
/**
* 존재하지 않는 슬러그는 false를 반환하는지 확인
*/
public function test_slug_exists_returns_false_for_nonexistent(): void
{
$this->assertFalse($this->service->slugExists('test-svc-no-slug'));
}
/**
* excludeId로 자기 자신을 제외할 수 있는지 확인
*/
public function test_slug_exists_excludes_own_id(): void
{
$page = $this->createTestPage('test-svc-exclude');
$this->assertFalse($this->service->slugExists('test-svc-exclude', $page->id));
}
// ─── getPublishedPageBySlug ────────────────────────
/**
* 발행된 페이지를 슬러그로 조회할 수 있는지 확인
*/
public function test_get_published_page_by_slug(): void
{
Page::factory()->published()->create([
'slug' => 'test-svc-pub-slug',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$page = $this->service->getPublishedPageBySlug('test-svc-pub-slug');
$this->assertNotNull($page);
$this->assertEquals('test-svc-pub-slug', $page->slug);
}
/**
* 미발행 페이지는 슬러그로 조회할 수 없는지 확인
*/
public function test_get_published_page_by_slug_returns_null_for_draft(): void
{
Page::factory()->create([
'slug' => 'test-svc-draft-slug',
'published' => false,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$page = $this->service->getPublishedPageBySlug('test-svc-draft-slug');
$this->assertNull($page);
}
/**
* allowUnpublished=true 이면 미발행 페이지도 반환하는지 확인 (미리보기)
*/
public function test_get_published_page_by_slug_returns_draft_when_preview_allowed(): void
{
Page::factory()->create([
'slug' => 'test-svc-draft-preview',
'published' => false,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$page = $this->service->getPublishedPageBySlug('test-svc-draft-preview', true);
$this->assertNotNull($page);
$this->assertEquals('test-svc-draft-preview', $page->slug);
$this->assertFalse((bool) $page->published);
}
/**
* allowUnpublished=true 여도 존재하지 않는 슬러그는 null 을 반환하는지 확인
*/
public function test_get_published_page_by_slug_returns_null_for_missing_even_when_preview_allowed(): void
{
$page = $this->service->getPublishedPageBySlug('test-svc-no-such-slug', true);
$this->assertNull($page);
}
// ─── scope 검증 (AccessDeniedHttpException) ────
/**
* getPage()에서 scope=self일 때 타인의 페이지 조회 시 AccessDeniedHttpException을 던지는지 확인
*/
public function test_get_page_throws_exception_for_scope_denied(): void
{
// scope=self 사용자 설정
$scopeUser = $this->setupScopeUser(ScopeType::Self);
Auth::setUser($scopeUser);
$this->actingAs($scopeUser);
// 다른 사용자가 만든 페이지
$page = Page::factory()->create([
'slug' => 'test-svc-scope-show',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$this->expectException(AccessDeniedHttpException::class);
$this->service->getPage($page->id);
}
/**
* getPage()에서 scope=self일 때 자기 페이지는 정상 조회되는지 확인
*/
public function test_get_page_allows_own_page_with_scope_self(): void
{
$scopeUser = $this->setupScopeUser(ScopeType::Self);
Auth::setUser($scopeUser);
$this->actingAs($scopeUser);
$page = Page::factory()->create([
'slug' => 'test-svc-scope-show-own',
'created_by' => $scopeUser->id,
'updated_by' => $scopeUser->id,
]);
$result = $this->service->getPage($page->id);
$this->assertEquals($page->id, $result->id);
}
/**
* updatePage()에서 scope=self일 때 타인의 페이지 수정 시 AccessDeniedHttpException을 던지는지 확인
*/
public function test_update_page_throws_exception_for_scope_denied(): void
{
$scopeUser = $this->setupScopeUser(ScopeType::Self);
Auth::setUser($scopeUser);
$this->actingAs($scopeUser);
$page = Page::factory()->create([
'slug' => 'test-svc-scope-update',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$this->expectException(AccessDeniedHttpException::class);
$this->service->updatePage($page, [
'title' => ['ko' => '수정 시도', 'en' => 'Attempt'],
]);
}
/**
* deletePage()에서 scope=self일 때 타인의 페이지 삭제 시 AccessDeniedHttpException을 던지는지 확인
*/
public function test_delete_page_throws_exception_for_scope_denied(): void
{
$scopeUser = $this->setupScopeUser(ScopeType::Self);
Auth::setUser($scopeUser);
$this->actingAs($scopeUser);
$page = Page::factory()->create([
'slug' => 'test-svc-scope-delete',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$this->expectException(AccessDeniedHttpException::class);
$this->service->deletePage($page);
}
/**
* changePublishStatus()에서 scope=self일 때 타인의 페이지 발행 변경 시 AccessDeniedHttpException을 던지는지 확인
*/
public function test_change_publish_status_throws_exception_for_scope_denied(): void
{
$scopeUser = $this->setupScopeUser(ScopeType::Self);
Auth::setUser($scopeUser);
$this->actingAs($scopeUser);
$page = Page::factory()->create([
'slug' => 'test-svc-scope-publish',
'published' => false,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$this->expectException(AccessDeniedHttpException::class);
$this->service->changePublishStatus($page, true);
}
/**
* restoreVersion()에서 scope=self일 때 타인의 페이지 버전 복원 시 AccessDeniedHttpException을 던지는지 확인
*/
public function test_restore_version_throws_exception_for_scope_denied(): void
{
$scopeUser = $this->setupScopeUser(ScopeType::Self);
Auth::setUser($scopeUser);
$this->actingAs($scopeUser);
$page = Page::factory()->create([
'slug' => 'test-svc-scope-restore',
'current_version' => 1,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$version = PageVersion::create([
'page_id' => $page->id,
'version' => 1,
'title' => $page->title,
'content' => $page->content ?? ['ko' => '', 'en' => ''],
'content_mode' => 'html',
'created_by' => $this->adminUser->id,
]);
$this->expectException(AccessDeniedHttpException::class);
$this->service->restoreVersion($page, $version->id);
}
// ─── 헬퍼 ────────────────────────────────────────
/**
* 테스트용 페이지를 생성합니다.
*/
private function createTestPage(string $slug): Page
{
return $this->service->createPage([
'slug' => $slug,
'title' => ['ko' => '테스트 페이지', 'en' => 'Test Page'],
'content' => ['ko' => '테스트 본문', 'en' => 'Test Content'],
'content_mode' => 'html',
]);
}
/**
* scope 권한이 설정된 사용자를 생성합니다.
*
* @param ScopeType $scopeType 스코프 타입
* @return User 생성된 사용자
*/
private function setupScopeUser(ScopeType $scopeType): User
{
$user = User::factory()->create();
$role = Role::firstOrCreate(
['identifier' => 'test_page_svc_scope'],
[
'name' => ['ko' => '페이지 서비스 스코프', 'en' => 'Page Service Scope'],
'is_active' => true,
]
);
$user->roles()->attach($role->id);
$permissionIds = [
'sirsoft-page.pages.read',
'sirsoft-page.pages.create',
'sirsoft-page.pages.update',
'sirsoft-page.pages.delete',
];
foreach ($permissionIds as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => ['ko' => $identifier, 'en' => $identifier],
'type' => PermissionType::Admin,
]
);
$permission->update([
'resource_route_key' => 'page',
'owner_key' => 'created_by',
]);
$role->permissions()->syncWithoutDetaching([
$permission->id => ['scope_type' => $scopeType],
]);
}
// PermissionHelper 캐시 초기화
$reflection = new \ReflectionClass(PermissionHelper::class);
$prop = $reflection->getProperty('permissionCache');
$prop->setAccessible(true);
$prop->setValue(null, []);
return $user;
}
}