Merge pull request from gnuboard/chym1217/issue424

fix(page): 페이지 검수 이슈 일괄 수정
This commit is contained in:
eunhye
2026-07-06 09:54:05 +09:00
committed by GitHub
87 changed files with 9451 additions and 6343 deletions
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-06
### Added
- 페이지 버전 이력의 변경 필드명 라벨 일본어 번역 추가 (`versions.field_labels.*`) — 버전 이력의 "변경내역" 항목이 일본어 로케일에서 필드 키 대신 자연스러운 라벨로 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -62,4 +62,7 @@ return [
'array' => '順序情報の形式が正しくありません。',
],
],
'attributes' => [
'search' => '検索ワード',
],
];
@@ -160,7 +160,13 @@
"changed_fields": "変更: {{fields}}",
"restored_from": "v{{version}}から復元",
"restore_guide": "表示ボタンをクリックすると、内容を確認して復元できます。添付ファイルはバージョン管理に含まれていません。",
"preview_modal_editor_label": "バージョンプレビュー"
"preview_modal_editor_label": "バージョンプレビュー",
"field_labels": {
"title": "タイトル",
"content": "内容",
"seo_meta": "SEO情報",
"content_mode": "編集モード"
}
}
}
}
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-page) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-page) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-page",
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-06
### Changed
- 첨부 개수 초과 안내 문구(`attachment.upload_limit_exceeded`)를 이미지 전용 표현에서 일반 파일에도 맞는 범용 표현으로 갱신했습니다 — 한국어/영어 원문 변경에 맞춰 일본어 번역을 동기화했습니다.
## [1.0.0] - 2026-07-01
### Added
@@ -7,11 +7,11 @@
"deleting": "削除中...",
"drop_or_click": "ファイルをドラッグするか、クリックしてアップロード",
"upload_limit": "最大{{maxFiles}}個、1個あたり{{maxSize}}MB",
"upload_limit_exceeded": "最大{{max}}個まで添付できます。",
"attached_count": "{{count}}/{{max}}個添付済み",
"max_size": "最大{{size}}MB",
"drag_to_reorder": "ドラッグして順序を変更",
"reorder_failed": "順序変更に失敗しました。",
"primary_image": "メイン画像",
"set_as_primary": "メイン画像に設定",
"upload_limit_exceeded": "画像は最大{{max}}枚までアップロードできます。"
"set_as_primary": "メイン画像に設定"
}
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-06
### Added
- 미발행 페이지 관리자 미리보기 안내 배너(`user.page.preview_banner`) 일본어 번역 추가 — 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때의 안내 문구가 일본어 로케일에서 자연스럽게 표시됩니다.
- 첨부 개수 초과 안내 문구(`attachment.upload_limit_exceeded`) 일본어 번역 추가 — 게시글 첨부를 허용 개수보다 많이 선택했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -9,5 +9,6 @@
"reorder_failed": "ファイルの順序変更に失敗しました",
"drag_to_reorder": "ドラッグして順序を変更",
"primary_image": "代表画像",
"set_as_primary": "代表画像に設定"
"set_as_primary": "代表画像に設定",
"upload_limit_exceeded": "最大{{max}}個まで添付できます。"
}
@@ -51,7 +51,8 @@
"published_at": "公開日",
"create_page": "ページ作成",
"attachments": "添付ファイル",
"download": "ダウンロード"
"download": "ダウンロード",
"preview_banner": "未公開ページをプレビュー中です"
},
"mypage": {
"page_title": "マイページ",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-basic",
@@ -4,6 +4,31 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-06
### Fixed
- 페이지를 삭제한 뒤 같은 슬러그(주소)로 새 페이지를 다시 만들 수 있도록 수정했습니다. 이전에는 삭제한 페이지의 슬러그가 남아 "이미 사용 중"으로 표시되어 동일 주소로 페이지를 다시 만들 수 없었습니다.
- 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다.
- 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다.
- 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다.
- 관리자 페이지 상세 화면에서 첨부 이미지 미리보기(썸네일)가 표시되지 않고 다운로드 버튼도 보이지 않던 문제를 수정했습니다. 이제 발행 여부와 관계없이 썸네일이 표시되고, 다운로드 버튼으로 첨부파일을 내려받을 수 있습니다. 미발행 페이지의 첨부파일 다운로드는 페이지 조회 권한이 있는 관리자에게만 허용되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 차단됩니다.
- 사용자 페이지 화면의 첨부파일 목록에서도 이미지 첨부는 썸네일로 미리 볼 수 있도록 했습니다. 이전에는 이미지도 파일 아이콘으로만 표시되었습니다.
- 페이지를 수정·삭제하거나 이전 버전으로 복원한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다.
- 페이지 목록에서 여러 페이지를 한 번에 발행 / 미발행할 때 활동 이력에 발행 여부가 기록되지 않던 문제를 수정했습니다. 이제 일괄 처리에서도 페이지별로 발행 / 미발행 기록이 남습니다.
- 페이지 관리 목록에서 검색 조건 '전체'로 슬러그를 검색해도 결과가 나오지 않던 문제를 수정했습니다 — '전체'는 제목과 슬러그를 함께 검색하고, '제목'을 고르면 제목만 검색됩니다.
- 페이지 관리 목록의 페이지당 표시 개수 기본값이 화면 표시(20건)와 실제 조회(15건) 간에 어긋나던 문제를 수정했습니다 — 기본값을 20건으로 통일했습니다.
- 페이지 관리 목록에서 아주 긴 검색어를 입력하면 오류 화면이 뜨던 문제를 수정했습니다 — 검색어 길이를 100자로 제한해 안내 메시지로 처리합니다.
- 페이지 상세의 버전 이력에서 "변경내역" 항목이 `title`·`content` 같은 영문 필드명으로 표시되던 문제를 수정했습니다 — 이제 "제목", "내용", "SEO 정보" 처럼 알아보기 쉬운 이름으로 표시됩니다.
- 관리자가 발행 전 페이지를 사용자 화면에서 미리 확인할 수 있게 되었습니다. 미리보기 화면에는 미발행 상태임을 알리는 안내가 표시되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 노출되지 않습니다.
- 페이지 첨부파일을 올릴 때 허용 용량을 초과하거나 허용되지 않은 형식의 파일을 선택하면 안내 메시지가 표시됩니다 — 이전에는 업로드가 조용히 막히기만 하고 이유를 알 수 없었습니다.
- 모바일 화면의 페이지 목록에서 검색어를 입력하고 검색해도 검색이 적용되지 않고 전체 목록이 그대로 나오던 문제를 수정했습니다. 이제 모바일에서도 입력한 검색어로 정상 검색됩니다.
- 페이지 등록 / 수정 화면에서 취소를 눌렀을 때 이동 위치가 일정하지 않던 문제를 수정했습니다 — 수정 화면에서는 해당 페이지 상세로, 새 페이지 등록 화면에서는 목록으로 돌아갑니다.
### Changed
- 페이지 관리 화면(목록·상세·등록/수정)의 버튼·선택 상자·카드 여백 등 전반적인 구성을 다른 관리 화면과 통일해 정돈했습니다. 상세 화면의 첨부파일 목록은 처음부터 펼쳐져 보이고, 어두운 화면(다크 모드)에서 첨부 항목과 카드가 더 또렷하게 구분됩니다.
## [1.0.0] - 2026-07-01
### Changed
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-page",
"description": "Page module for Gnuboard7",
"type": "library",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"autoload": {
"psr-4": {
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('pages') || ! Schema::hasColumn('pages', 'deleted_at')) {
return;
}
// 잔존 soft-delete 페이지 행을 물리 삭제한다.
// page_attachments.page_id 의 cascadeOnDelete FK 로 연관 첨부 행도 함께 제거된다.
// (첨부 물리 파일은 페이지 삭제 시점에 이미 제거된 상태 — 여기서는 DB 행만 정리)
DB::table('pages')->whereNotNull('deleted_at')->delete();
Schema::table('pages', function (Blueprint $table) {
// 소프트 삭제 컬럼 제거 (페이지는 물리 삭제 방식으로 전환)
$table->dropSoftDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('pages') || Schema::hasColumn('pages', 'deleted_at')) {
return;
}
Schema::table('pages', function (Blueprint $table) {
// 소프트 삭제 컬럼 복원
$table->softDeletes();
});
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('page_attachments') || ! Schema::hasColumn('page_attachments', 'deleted_at')) {
return;
}
// 잔존 soft-delete 첨부 행을 물리 삭제한다.
// (물리 파일은 첨부 삭제 시점에 이미 제거된 상태 — 여기서는 DB 행만 정리)
DB::table('page_attachments')->whereNotNull('deleted_at')->delete();
Schema::table('page_attachments', function (Blueprint $table) {
// deleted_at 인덱스 제거 (소프트 삭제 미사용으로 불필요)
$table->dropIndex(['deleted_at']);
// 소프트 삭제 컬럼 제거 (첨부는 물리 삭제 방식으로 전환)
$table->dropSoftDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('page_attachments') || Schema::hasColumn('page_attachments', 'deleted_at')) {
return;
}
Schema::table('page_attachments', function (Blueprint $table) {
// 소프트 삭제 컬럼 복원
$table->softDeletes()->comment('소프트 삭제 일시');
// deleted_at 인덱스 복원
$table->index('deleted_at');
});
}
};
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "페이지",
"en": "Page"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"description": {
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-page",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-page",
"version": "1.0.0",
"version": "1.0.1",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-page",
"version": "1.0.0",
"version": "1.0.1",
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -0,0 +1,89 @@
/**
* @file admin-page-detail-attachment-download.test.tsx
* @description 관리자 페이지 상세 첨부 카드가 토큰 동반 핸들러로 다운로드하고, 다운로드 아이콘이
* 유효한 Font Awesome 이름을 쓰는지 회귀 (별건: 관리자 상세 첨부 썸네일·다운로드 미노출).
*
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
*
* 결함 A: 첨부 카드(id: "attachment_card")가 <A href="{{...download_url}}"> 브라우저 직접
* 링크였다. 커밋 d48c684 로 download_url 이 auth:sanctum 보호 admin 라우트가 되어 <a>
* 네비게이션에 토큰이 실리지 않아 401 로 깨졌다.
* 결함 B: 다운로드 아이콘 name="arrow-down-tray"(Heroicons)는 FA 에 없어 조용히 미렌더.
*
* 수정: 카드를 Div + actions:[{click → downloadAttachment}] 로 전환(토큰 동반),
* 아이콘 name 을 유효한 FA 이름 "download" 로 교체.
*
* @scenario surface=admin_page_detail
* @effects download_card_uses_handler_not_anchor
* @effects download_icon_valid_fa_name
*/
import { describe, it, expect } from 'vitest';
import adminPageDetail from '../../../layouts/admin/admin_page_detail.json';
type Node = Record<string, unknown> & { children?: unknown; actions?: unknown };
function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] {
const result: Node[] = [];
const walk = (cur: unknown): void => {
if (Array.isArray(cur)) {
cur.forEach(walk);
return;
}
if (cur && typeof cur === 'object') {
const obj = cur as Node;
if (predicate(obj)) {
result.push(obj);
}
Object.values(obj).forEach(walk);
}
};
walk(node);
return result;
}
// Heroicons 계열 이름(FA 미존재) — 조용히 미렌더 회귀 차단용 블랙리스트
const HEROICON_NAMES = ['arrow-down-tray', 'arrow-up-tray', 'arrow-down-circle'];
describe('별건 — 관리자 페이지 상세 첨부 다운로드 카드', () => {
it('첨부 카드는 <A href> 직접 링크가 아니다 (토큰 미동반 401 회귀 차단)', () => {
const cards = collectNodes(adminPageDetail, (n) => typeof n.id === 'string' && n.id.startsWith('attachment_card'));
expect(cards.length).toBeGreaterThan(0);
for (const card of cards) {
const props = (card.props ?? {}) as Record<string, unknown>;
const isAnchorHref = card.name === 'A' && typeof props.href === 'string';
expect(isAnchorHref).toBe(false);
}
});
it('첨부 카드는 click 액션으로 downloadAttachment 핸들러를 호출한다', () => {
const cards = collectNodes(adminPageDetail, (n) => typeof n.id === 'string' && n.id.startsWith('attachment_card'));
const card = cards[0];
const actions = (card.actions ?? []) as Array<Record<string, unknown>>;
const clickAction = actions.find((a) => a.type === 'click');
expect(clickAction).toBeDefined();
expect(clickAction?.handler).toBe('downloadAttachment');
const params = (clickAction?.params ?? {}) as Record<string, unknown>;
expect(params.url).toContain('download_url');
expect(params.filename).toContain('original_filename');
});
it('다운로드 아이콘은 유효한 Font Awesome 이름을 쓴다 (arrow-down-tray 미렌더 회귀 차단)', () => {
const icons = collectNodes(adminPageDetail, (n) => n.name === 'Icon');
const iconNames = icons
.map((n) => (n.props as Record<string, unknown> | undefined)?.name)
.filter((v): v is string => typeof v === 'string');
for (const blacklisted of HEROICON_NAMES) {
expect(iconNames).not.toContain(blacklisted);
}
// 다운로드 아이콘이 실제로 존재하고 FA 유효 이름("download")을 쓴다
expect(iconNames).toContain('download');
});
});
@@ -291,6 +291,120 @@ describe('admin_page_list.json', () => {
expect(findById(adminPageList, 'published_true')).toBeNull();
expect(findById(adminPageList, 'published_false')).toBeNull();
});
// ─── F2: 모바일 검색 저장/읽기 저장소 정합 ───
/**
* responsive.portable 등 반응형 오버라이드 내부까지 순회하며 id 로 노드를 찾는다.
* (모바일 검색 요소는 search_row 의 responsive.portable.children 안에 중첩됨)
*/
function findByIdDeep(node: any, id: string): any | null {
if (!node || typeof node !== 'object') return null;
if (node.id === id) return node;
for (const child of node.children ?? []) {
const f = findByIdDeep(child, id);
if (f) return f;
}
if (node.slots) {
for (const sc of Object.values(node.slots)) {
if (Array.isArray(sc)) for (const c of sc) { const f = findByIdDeep(c, id); if (f) return f; }
}
}
for (const modal of node.modals ?? []) { const f = findByIdDeep(modal, id); if (f) return f; }
// 반응형 오버라이드 순회
if (node.responsive) {
for (const bp of Object.values(node.responsive) as any[]) {
for (const c of bp?.children ?? []) { const f = findByIdDeep(c, id); if (f) return f; }
}
}
return null;
}
it('[F2] 모바일 검색필드 select 의 change setState 가 target:global 을 쓰지 않음 (읽기는 _local 이므로 저장도 _local 이어야 검색 동작)', () => {
// 회귀 배경(실측): 모바일 select/input 이 target:"global" 로 _global.searchQuery 에 저장하는데,
// 검색 버튼·value 바인딩은 {{_local.searchQuery}} 를 읽음 → 검색 버튼이 항상 빈 값 전송 →
// 모바일에서 검색이 전혀 걸리지 않음(전체 목록 그대로). 데스크톱은 target 없이 _local 에 저장해 정상.
const select = findByIdDeep(adminPageList, 'mobile_search_field_select');
expect(select).not.toBeNull();
const changeAction = (select.actions ?? []).find((a: any) => a.type === 'change');
expect(changeAction?.handler).toBe('setState');
expect(changeAction?.params?.target).not.toBe('global');
});
it('[F2] 모바일 검색 input 의 change setState 가 target:global 을 쓰지 않음 (데스크톱과 동일하게 _local 저장)', () => {
const input = findByIdDeep(adminPageList, 'mobile_search_input');
expect(input).not.toBeNull();
const changeAction = (input.actions ?? []).find((a: any) => a.type === 'change');
expect(changeAction?.handler).toBe('setState');
expect(changeAction?.params?.target).not.toBe('global');
// 저장 키는 searchQuery 유지
expect(changeAction?.params).toHaveProperty('searchQuery');
});
it('[F2] 데스크톱 검색 select/input 은 이미 target:global 을 쓰지 않음 (정상 기준선 — 회귀 대조군)', () => {
const dField = findById(adminPageList, 'search_field_select');
const dInput = findById(adminPageList, 'search_input');
const fieldChange = (dField.actions ?? []).find((a: any) => a.type === 'change');
const inputChange = (dInput.actions ?? []).find((a: any) => a.type === 'change');
expect(fieldChange?.params?.target).not.toBe('global');
expect(inputChange?.params?.target).not.toBe('global');
});
// ─── C1·C2·C5: 시맨틱 클래스 정리 ───
it('[C1] 검색바 안 필드 셀렉트는 pill(select-composite), 툴바 발행/정렬/개수는 테두리(border+bg-white) — 이커머스 목록 관행', () => {
// 관행(이커머스 shipping_policy/order/product 목록): 검색바 내부 필드 셀렉트 = pill,
// 목록 툴바의 필터/정렬/개수 셀렉트 = 테두리(bg-white border). 위치에 따라 외형이 다름.
const searchField = findById(adminPageList, 'search_field_select');
expect(searchField.props.className).toContain('select-composite');
for (const id of ['published_filter_select', 'sort_select', 'per_page_select']) {
const sel = findById(adminPageList, id);
expect(sel, id).not.toBeNull();
// 툴바 셀렉트는 테두리 형태 — select-composite(pill) 아님
expect(sel.props.className, id).not.toContain('select-composite');
expect(sel.props.className, id).toContain('border');
expect(sel.props.className, id).toContain('bg-white');
}
});
it('[C2] 검색 버튼(데스크톱/모바일)이 btn btn-primary 를 사용하고 bg-gray-800 하드코딩을 쓰지 않음', () => {
// 표준(이커머스 주문/상품 목록): 검색·필터 적용 버튼 = btn btn-primary(파랑).
const dBtn = findById(adminPageList, 'search_button');
const mBtn = findByIdDeep(adminPageList, 'mobile_search_button');
expect(dBtn.props.className).toContain('btn btn-primary');
expect(dBtn.props.className).not.toContain('bg-gray-800');
expect(mBtn.props.className).toContain('btn btn-primary');
expect(mBtn.props.className).toContain('w-full');
expect(mBtn.props.className).not.toContain('bg-gray-800');
});
it('[C2] 등록 버튼이 btn btn-primary 를 사용하고 bg-blue-600 하드코딩을 쓰지 않음', () => {
const addBtn = findById(adminPageList, 'add_page_button');
expect(addBtn.props.className).toContain('btn btn-primary');
expect(addBtn.props.className).not.toContain('bg-blue-600');
});
it('[C5] 목록 모달 버튼에 하드코딩 색상(bg-green-600/gray-600/red-600/bg-white...border) 이 남아있지 않음', () => {
const modalsStr = JSON.stringify((adminPageList as any).modals ?? []);
expect(modalsStr).not.toContain('bg-green-600');
expect(modalsStr).not.toContain('bg-gray-600');
expect(modalsStr).not.toContain('bg-red-600');
// 취소 버튼 하드코딩 패턴(bg-white...border) 제거 확인
expect(modalsStr).not.toContain('bg-white dark:bg-gray-800 text-gray-700');
// 시맨틱 btn 계열 사용 확인 (취소=secondary, 삭제=danger, 그 외 확인=primary)
expect(modalsStr).toContain('btn btn-secondary');
expect(modalsStr).toContain('btn btn-primary');
expect(modalsStr).toContain('btn btn-danger');
});
it('[C5] 모달 버튼에 flex-center gap-* 접두사가 없음 (btn 자체가 inline-flex 정렬 — 취소/확인 크기 정합)', () => {
// 회귀 배경: btn 은 이미 inline-flex items-center justify-center gap-2 를 포함.
// 위에 flex-center(=flex items-center)를 덧붙이면 inline-flex→flex 로 덮어써 버튼 폭이
// 늘어나 취소/확인 버튼 크기가 어긋남(PO 지적: 닫기≠복원). btn 만 남겨 정합.
const modalsStr = JSON.stringify((adminPageList as any).modals ?? []);
expect(modalsStr).not.toMatch(/flex-center gap-[\d.]+ btn/);
});
});
// ─────────────────────────────────────────────
@@ -568,6 +682,157 @@ describe('admin_page_form.json', () => {
expect(emptyDivWrapper, id).toBeUndefined();
}
});
// ─── 수정→등록 SPA 전환 시 폼/첨부 잔존 차단 ───
it('FileUploader initialFiles 가 _local.form.attachments 에 바인딩됨 (등록 모드 전환 시 빈 배열로 동기화되어 이전 첨부 잔존 차단)', () => {
// 회귀 배경: pageData?.data?.attachments 바인딩은 등록 모드에서 pageData 가 없어
// initialFiles 참조가 불변 → FileUploader 동기화 useEffect 미발화 → 이전 첨부 잔존.
// _local.form.attachments 바인딩은 init_actions 의 form 초기화로 참조가 바뀌어 동기화 트리거.
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
expect(uploaders.length).toBeGreaterThan(0);
expect(uploaders[0].props.initialFiles).toContain('_local.form');
expect(uploaders[0].props.initialFiles).toContain('attachments');
expect(uploaders[0].props.initialFiles).not.toContain('pageData');
});
it('생성 모드 init_actions 가 form 을 null 로 비운 뒤 빈 기본값으로 초기화함 (수정→등록 전환 시 이전 form 잔존 차단)', () => {
const initActions = (adminPageForm as any).init_actions as any[];
// 생성 모드(if !route?.id) 의 form 관련 setState 액션들
const createFormActions = initActions.filter(
(a) => a.if && a.if.includes('!route?.id') && a.params?.target === 'local' && 'form' in (a.params ?? {}),
);
// form: null 로 먼저 비우는 액션
const nullReset = createFormActions.find((a) => a.params.form === null);
expect(nullReset, 'form=null 리셋 액션').toBeDefined();
// form: {기본값} 으로 채우는 액션 (attachments: [] 포함)
const defaultInit = createFormActions.find(
(a) => a.params.form && typeof a.params.form === 'object' && Array.isArray(a.params.form.attachments),
);
expect(defaultInit, 'form 기본값 초기화 액션').toBeDefined();
expect(defaultInit.params.form.attachments).toEqual([]);
});
it('생성 모드 init_actions 가 슬러그 검증 상태(slugChecked/slugAvailable)를 false 로 리셋함 (이전 "사용 가능" 메시지 잔존 차단)', () => {
const initActions = (adminPageForm as any).init_actions as any[];
const slugReset = initActions.find(
(a) =>
a.if &&
a.if.includes('!route?.id') &&
a.params?.slugChecked === false &&
a.params?.slugAvailable === false,
);
expect(slugReset).toBeDefined();
});
it('slug_hint URL 미리보기가 빈 슬러그일 때 || fallback 으로 example 치환됨 ({{slug}} 미치환 회귀 차단)', () => {
// 회귀 배경: ?? 'example' 은 빈 문자열('')을 통과시켜 다국어 파라미터가 빈 값이 되고
// {{slug}} 가 치환되지 않은 채 노출됨. || 'example' 로 빈 문자열도 fallback 처리.
const slugField = findById(adminPageForm, 'slug_field');
const hintSpan = (slugField.children ?? []).find(
(c: any) => typeof c.text === 'string' && c.text.includes('slug_hint'),
);
expect(hintSpan).toBeDefined();
expect(hintSpan.text).toContain("|| 'example'");
expect(hintSpan.text).not.toContain("?? 'example'");
});
// ─── 첨부 순서 — form.attachments 동기화 (이커머스 패턴) ───
it('FileUploader 에 onUploadComplete/onReorder/onRemove 액션이 form.attachments 를 동기화함', () => {
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
const actions = uploaders[0].actions ?? [];
const events = actions.map((a: any) => a.event);
expect(events).toContain('onUploadComplete');
expect(events).toContain('onReorder');
expect(events).toContain('onRemove');
// onReorder 가 form.attachments 를 최종 순서로 덮어씀
const onReorder = actions.find((a: any) => a.event === 'onReorder');
expect(onReorder.handler).toBe('setState');
expect(onReorder.params['form.attachments']).toContain('$args[0]');
});
// ─── 첨부 검증 실패 안내 (용량/확장자/개수 초과 toast) ───
it('FileUploader 에 onUploadError → toast 액션이 있어 검증 실패를 사용자에게 안내함', () => {
// 회귀 배경: onUploadError 핸들러 부재로 용량/확장자/개수 초과 시 백엔드/클라이언트
// 차단은 되나 toast 안내가 없어 사용자가 이유를 알 수 없었음.
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
const actions = uploaders[0].actions ?? [];
const onUploadError = actions.find((a: any) => a.event === 'onUploadError');
expect(onUploadError, 'onUploadError 액션').toBeDefined();
expect(onUploadError.handler).toBe('toast');
expect(onUploadError.params.type).toBe('error');
// 훅이 만든 에러 메시지 문자열($args[0])을 그대로 표시
expect(onUploadError.params.message).toContain('$args[0]');
});
it('FileUploader reorder 엔드포인트가 수정 모드(route.id)에서만 활성화됨 (생성 모드는 temp 첨부라 reorder 불가)', () => {
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
expect(uploaders[0].props.apiEndpoints.reorder).toContain('route?.id');
});
// ─── UI 검토 M1·M6 회귀 차단 ───
it('[M1-a] 검증에러 제목 클래스가 공백으로 분리됨 (font-medium + text-danger-soft, 병합 클래스 금지)', () => {
// 회귀 배경: "font-mediumtext-danger-soft" 처럼 공백 없이 붙으면 두 클래스 모두 무효
// → 빌드 CSS 에 해당 클래스 부재 → 빨간색/굵기 미적용.
const errorBox = findById(adminPageForm, 'error_message_box');
expect(errorBox).not.toBeNull();
const boxStr = JSON.stringify(errorBox);
// 병합된 무효 클래스가 없어야 함
expect(boxStr).not.toContain('font-mediumtext-danger-soft');
// 제목 H3 은 두 클래스를 공백으로 분리해서 보유
const heading = findComponentsByName(errorBox, 'H3')[0];
expect(heading).toBeDefined();
const cls = heading.props.className ?? '';
expect(cls).toContain('font-medium');
expect(cls).toContain('text-danger-soft');
});
it('[M1-b] 에러 박스에 하단 여백(mb-*)이 있어 아래 카드와 붙지 않음', () => {
// 회귀 배경: alert-danger 자산에 margin-bottom 없음 → 바로 아래 admin-card 와 딱 붙음.
// 같은 폼의 read_only_banner 는 mb-6 으로 간격을 주므로 에러 박스도 통일.
const errorBox = findById(adminPageForm, 'error_message_box');
expect(errorBox).not.toBeNull();
expect(errorBox.props.className).toContain('alert-danger');
expect(errorBox.props.className).toMatch(/\bmb-\d/);
});
it('[C4] 저장 버튼이 btn btn-primary 시맨틱 클래스를 쓰고 saving 시 색을 바꾸지 않음 (board/ecommerce norm)', () => {
// 회귀 배경: saving 시 bg-blue-400 로 색을 바꾸던 하드코딩(다크모드 미지원) → btn-primary 고정.
// 저장중 표현은 색이 아니라 disabled + spinner 아이콘 + 라벨 토글로.
const saveBtn = findById(adminPageForm, 'footer_save_button');
expect(saveBtn).not.toBeNull();
const cls = saveBtn.props.className ?? '';
expect(cls).toContain('btn btn-primary');
// saving 조건부 색 변경(bg-blue-400/bg-blue-600) 제거
expect(cls).not.toContain('bg-blue-400');
expect(cls).not.toContain('bg-blue-600');
// disabled 는 saving 으로 유지
expect(saveBtn.props.disabled).toContain('saving');
// spinner 아이콘이 saving 시 표시됨
const spinner = findComponentsByName(saveBtn, 'Icon').find(
(i: any) => i.props?.name === 'spinner',
);
expect(spinner).toBeDefined();
expect(spinner.if).toContain('saving');
});
it('[M6] 취소 버튼이 navigateBack 이 아니라 navigate 로 목록/상세를 명시함', () => {
// 회귀 배경: navigateBack(history.back)은 직전 위치(다른 메뉴)로 튀거나 직접 진입 시 멈춤.
// 수정 모드는 상세(/admin/pages/{id}), 등록 모드는 목록(/admin/pages) 으로 명시 이동.
const cancelBtn = findById(adminPageForm, 'footer_cancel_button');
expect(cancelBtn).not.toBeNull();
const action = (cancelBtn.actions ?? []).find((a: any) => a.type === 'click');
expect(action).toBeDefined();
expect(action.handler).toBe('navigate');
expect(action.handler).not.toBe('navigateBack');
// route.id 분기로 수정=상세 / 등록=목록
const path = action.params?.path ?? '';
expect(path).toContain('/admin/pages');
expect(path).toContain('route');
});
});
// ─────────────────────────────────────────────
@@ -717,4 +982,213 @@ describe('admin_page_detail.json', () => {
// $locale 로 fallback 하는 표현식이 존재해야 함 (탭 + 콘텐츠 표현식)
expect(layoutStr).toMatch(/_local\.lang \?\? \$locale/);
});
// ─── 변경내역 필드명 다국어 매핑 ───
/**
* 버전 이력 테이블의 변경내역(changes_summary) 컬럼에서
* changed_fields 를 표시하는 cellChild 를 찾는다.
*/
function findChangedFieldsCell(): any {
const grids = findComponentsByName(adminPageDetail, 'DataGrid');
const col = grids[0]?.props?.columns?.find((c: any) => c.field === 'changes_summary');
expect(col, 'changes_summary 컬럼').toBeDefined();
// changed_fields 길이 > 0 조건을 가진 cellChild (라벨 표시 셀)
return (col.cellChildren ?? []).find(
(cc: any) =>
typeof cc.if === 'string' &&
cc.if.includes('changed_fields?.length > 0'),
);
}
it('변경내역 셀이 changed_fields 를 raw 영문 식별자로 그대로 join 하지 않음 (제목/내용 다국어 매핑)', () => {
const cell = findChangedFieldsCell();
expect(cell, 'changed_fields 표시 셀').toBeDefined();
// 회귀 차단: 영문 식별자 배열을 그대로 join 하면 화면에 title, content 노출
expect(cell.text).not.toMatch(/changed_fields\?\.join\(', '\)\s*\?\?\s*'-'/);
});
it('변경내역 셀이 field_labels 다국어 키로 각 필드를 변환함 ($t 매핑)', () => {
const cell = findChangedFieldsCell();
expect(cell, 'changed_fields 표시 셀').toBeDefined();
const cellStr = JSON.stringify(cell);
// field_labels 키 경로 + $t 함수 변환 사용
expect(cellStr).toContain('field_labels');
expect(cellStr).toContain('$t');
});
it('field_labels 4종(title/content/seo_meta/content_mode) 키 경로를 참조함 (PageService compareFields 정합)', () => {
const cell = findChangedFieldsCell();
const cellStr = JSON.stringify(cell);
// 동적 키 prefix 가 versions.field_labels 를 가리킴
expect(cellStr).toContain('sirsoft-page.admin.page.detail.versions.field_labels');
});
// ─── UI 검토 M2·M3·M4·M5 회귀 차단 ───
it('[M5] onError 메시지 바인딩이 $error 가 아닌 error 컨텍스트 변수를 사용함 (엔진 onError context 키는 error)', () => {
// 회귀 배경: 엔진은 onError 콜백 컨텍스트를 `error` 키로 제공(ActionDispatcher).
// `$error` 는 undefined → message 표현식이 항상 fallback 문구로 떨어져
// 서버가 준 실제 실패 사유가 사용자에게 노출되지 않음. 목록 모달은 이미 error.* 사용.
const layoutStr = JSON.stringify(adminPageDetail);
expect(layoutStr).not.toContain('$error');
// 삭제/복원 실패 onError 는 error.message fallback 패턴을 사용해야 함
expect(layoutStr).toContain('error.message');
});
it('[M3] 첨부 카드 hover 배경이 다크모드 기본 상태에 색을 박지 않음 (admin-card 배경과 중복 방지)', () => {
// 회귀 배경: "hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50" 는
// 다크모드에서 hover 가 아닌 기본 상태에 bg-gray-800 을 상시 적용 → admin-card 배경과 중복.
const attachmentsSection = findById(adminPageDetail, 'attachments_section');
const cards = findComponentsByName(attachmentsSection, 'Div').filter(
(n: any) => typeof n.props?.className === 'string' && n.props.className.includes('hover:bg-gray-50'),
);
expect(cards.length).toBeGreaterThan(0);
for (const card of cards) {
// 기본 상태 다크 배경 토큰(dark:bg-gray-800) 을 hover 카드에서 제거
expect(card.props.className).not.toContain('dark:bg-gray-800');
}
});
it('[토글] 첨부 목록이 기본 열림 상태 (showAttachments ?? true) — 토글/아이콘/본문 3곳 일관', () => {
// PO 요청: 첨부 목록이 처음부터 펼쳐져 보이도록.
const section = findById(adminPageDetail, 'attachments_section');
const sectionStr = JSON.stringify(section);
// 기본 열림: (_local.showAttachments ?? true) 패턴 사용 (fallback true)
expect(sectionStr).toContain('_local.showAttachments ?? true');
// 이전 fallback 없는 raw 참조가 본문 if / 아이콘에 남지 않아야 함
// (토글 액션의 !(...) 는 유지되므로 정확 매칭으로 raw if 만 차단)
const listNode = findById(section, 'attachments_list_{{att_idx}}')
?? (section.children ?? []).find((c: any) => typeof c.if === 'string' && c.if.includes('showAttachments'));
expect(listNode).toBeDefined();
expect(listNode.if).toContain('showAttachments ?? true');
});
it('[메타카드] info_grid 가 grid-2col-responsive (모바일 1열 → PC 2열) 를 사용함', () => {
// PO 결정: 메타 4항목(작성자/발행일시/생성일/수정일)을 PC 2열·모바일 1열로.
const infoGrid = findById(adminPageDetail, 'info_grid');
expect(infoGrid).not.toBeNull();
expect(infoGrid.props.className).toContain('grid-2col-responsive');
// 세로 나열 원인이던 panel-header-row 단독 사용 금지
expect(infoGrid.props.className).not.toBe('panel-header-row');
});
it('[헤더통일 A] 카드 섹션 헤더가 card-title + px-6/panel-header 미사용 (admin-card 패딩만 의존, 이중여백·구분선 제거)', () => {
// 조사 결과 board/ecommerce norm = admin-card > card-title (헤더에 px-6/panel-header 없음).
// 회귀 배경: admin-card 자체 p-4 위에 헤더가 px-6(24px)+panel-header(border-b)를 더해
// 헤더 앞 여백이 16+24=40px 로 이중 적용되고 구분선이 어색하게 뜸(PO 지적).
const headerIds = ['content_header', 'seo_header', 'versions_header'];
for (const id of headerIds) {
const header = findById(adminPageDetail, id);
expect(header, id).not.toBeNull();
const cls = header.props?.className ?? '';
expect(cls, id).not.toContain('px-6');
expect(cls, id).not.toContain('panel-header');
const titleSpan = findComponentsByName(header, 'Span').find(
(s: any) => typeof s.text === 'string' && s.text.startsWith('$t:'),
);
expect(titleSpan, id).toBeDefined();
expect(titleSpan.props?.className, id).toContain('card-title');
expect(titleSpan.props?.className, id).not.toContain('text-body font-medium');
}
});
it('[헤더통일 A] 첨부 헤더가 paperclip 제거 + card-title + px-6/panel-header 미사용', () => {
const header = findById(adminPageDetail, 'attachments_header');
expect(header).not.toBeNull();
const cls = header.props?.className ?? '';
expect(cls).not.toContain('px-6');
expect(cls).not.toContain('panel-header');
// paperclip 아이콘 미존재
const icons = findComponentsByName(header, 'Icon');
const paperclip = icons.find((i: any) => i.props?.name === 'paperclip');
expect(paperclip).toBeUndefined();
// 제목 텍스트가 card-title
const titleSpan = findComponentsByName(header, 'Span').find(
(s: any) => typeof s.text === 'string' && s.text.includes('attachments'),
);
expect(titleSpan).toBeDefined();
expect(titleSpan.props?.className).toContain('card-title');
});
it('[헤더통일 A] info_header / info_grid 도 px-6 미사용 (admin-card 패딩만 의존)', () => {
const infoHeader = findById(adminPageDetail, 'info_header');
const infoGrid = findById(adminPageDetail, 'info_grid');
expect(infoHeader).not.toBeNull();
expect(infoGrid).not.toBeNull();
expect(infoHeader.props.className).not.toContain('px-6');
expect(infoGrid.props.className).not.toContain('px-6');
});
it('[C3] 상세 헤더 수정/삭제 버튼이 btn 시맨틱 클래스 사용 (하드코딩 bg-blue/red 제거, 다크모드 자산 내장)', () => {
// 회귀 배경: 헤더 수정/삭제 버튼이 bg-blue-600 / bg-red-600 하드코딩(다크모드 미지원).
// board/ecommerce norm = btn btn-primary / btn btn-danger (뒤로 버튼은 이미 btn btn-secondary).
const editBtn = findById(adminPageDetail, 'header_edit_button');
const deleteBtn = findById(adminPageDetail, 'header_delete_button');
expect(editBtn).not.toBeNull();
expect(deleteBtn).not.toBeNull();
// 데스크톱 className 이 btn 시맨틱, 하드코딩 색 제거
expect(editBtn.props.className).toContain('btn btn-primary');
expect(editBtn.props.className).not.toContain('bg-blue-600');
expect(deleteBtn.props.className).toContain('btn btn-danger');
expect(deleteBtn.props.className).not.toContain('bg-red-600');
// 모바일 responsive 오버라이드에도 하드코딩 색이 남지 않아야 함
expect(JSON.stringify(editBtn.responsive ?? {})).not.toContain('bg-blue-600');
expect(JSON.stringify(deleteBtn.responsive ?? {})).not.toContain('bg-red-600');
});
it('[C3] 삭제/복원 모달 확정 버튼이 btn 시맨틱 클래스 사용 (하드코딩 색 제거)', () => {
// deletePageModal 삭제 확정 = btn btn-danger, restore/preview 복원 = btn btn-primary
const deleteModal = (adminPageDetail as any).modals?.find((m: any) => m.id === 'deletePageModal');
const restoreModal = (adminPageDetail as any).modals?.find((m: any) => m.id === 'restoreVersionModal');
expect(deleteModal).toBeDefined();
expect(restoreModal).toBeDefined();
// 확정 버튼들에 하드코딩 solid 색 미사용
expect(JSON.stringify(deleteModal)).not.toContain('bg-red-600');
expect(JSON.stringify(restoreModal)).not.toContain('bg-blue-600');
});
it('[버전여백] 버전 이력 안내문(restore_guide)이 하단 여백을 갖고 좌우 마진(mx-6) 미사용 (카드 패딩에 위임)', () => {
// 회귀 배경: 헤더 → 안내문 → 표가 여백 없이 딱 붙음 → mb-4 부여.
// 추가: mx-6(좌우 24px)은 admin-card p-4 위에 이중 적용되어 안내문 양옆이 과하게 들어감 → 제거.
const section = findById(adminPageDetail, 'versions_section');
expect(section).not.toBeNull();
const guideBox = (section.children ?? []).find(
(c: any) => JSON.stringify(c).includes('restore_guide'),
);
expect(guideBox).toBeDefined();
const cls = guideBox.props?.className ?? '';
// 하단 여백 유지
expect(cls).toMatch(/\bmb-\d/);
// 좌우 마진(mx-*) 미사용 — 카드 폭에 맞게
expect(cls).not.toMatch(/\bmx-\d/);
});
it('[첨부구분] 각 첨부 항목이 자체 border-b 로 구분됨 (divide-y 는 iteration 자식에 미적용 → 항목별 하단 구분선)', () => {
// 회귀 배경: 리스트 컨테이너의 divide-y 는 iteration 항목이 wrapper 로 감싸져
// 직접 형제가 되지 못해 border-top 이 렌더되지 않음(childCount=1, MCP 실측).
// → 각 항목(attachment_card)에 직접 border-b 를 부여해 항목 구분.
const card = findById(adminPageDetail, 'attachment_card_{{att_idx}}');
expect(card).not.toBeNull();
const cls = card.props?.className ?? '';
expect(cls).toContain('border-b');
expect(cls).toContain('border-gray-200');
expect(cls).toContain('dark:border-gray-700');
});
it('[언어탭 반응형] 표시언어 영역이 flex-wrap + 힌트가 모바일에서 줄바꿈(w-full) (좁은 화면 탭 글자 깨짐 방지)', () => {
// 회귀 배경: 표시언어 라벨/탭/힌트가 flex 한 줄에 배치되어 모바일(390px)에서
// 공간 부족으로 탭 버튼 글자가 세로로 깨짐. 컨테이너 flex-wrap + 힌트 모바일 w-full 로 줄바꿈.
const container = findById(adminPageDetail, 'unified_lang_tabs');
expect(container).not.toBeNull();
expect(container.props.className).toContain('flex-wrap');
// 힌트 Span 이 모바일 responsive 로 전체폭(줄바꿈) 처리
const hint = findComponentsByName(container, 'Span').find(
(s: any) => typeof s.text === 'string' && s.text.includes('display_language_hint'),
);
expect(hint).toBeDefined();
const portableCls = hint.responsive?.portable?.props?.className ?? '';
expect(portableCls).toContain('w-full');
});
});
@@ -160,7 +160,13 @@
"initial": "Initial",
"changed_fields": "Changed: {{fields}}",
"restored_from": "Restored from v{{version}}",
"restore_guide": "Click View to check contents and restore. Attachments are not included in version management."
"restore_guide": "Click View to check contents and restore. Attachments are not included in version management.",
"field_labels": {
"title": "Title",
"content": "Content",
"seo_meta": "SEO",
"content_mode": "Edit Mode"
}
}
}
}
@@ -160,7 +160,13 @@
"initial": "최초 생성",
"changed_fields": "변경: {{fields}}",
"restored_from": "v{{version}}에서 복원",
"restore_guide": "보기 버튼을 클릭하면 내용을 확인하고 복원할 수 있습니다. 첨부파일은 버전 관리에 포함되지 않습니다."
"restore_guide": "보기 버튼을 클릭하면 내용을 확인하고 복원할 수 있습니다. 첨부파일은 버전 관리에 포함되지 않습니다.",
"field_labels": {
"title": "제목",
"content": "내용",
"seo_meta": "SEO 정보",
"content_mode": "편집 모드"
}
}
}
}
@@ -93,7 +93,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-secondary",
"disabled": "{{_global.isDeletingPage}}"
},
"actions": [
@@ -114,7 +114,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-danger",
"disabled": "{{_global.isDeletingPage || page?.data?.abilities?.can_delete !== true}}"
},
"actions": [
@@ -174,7 +174,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.delete_failed'}}"
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.delete_failed'}}"
}
}
]
@@ -249,7 +249,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-secondary",
"disabled": "{{_global.isRestoringVersion}}"
},
"actions": [
@@ -270,7 +270,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-primary",
"disabled": "{{_global.isRestoringVersion || page?.data?.abilities?.can_update !== true}}"
},
"actions": [
@@ -335,7 +335,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.restore_failed'}}"
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.restore_failed'}}"
}
}
]
@@ -554,7 +554,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
"className": "btn btn-secondary"
},
"actions": [
{
@@ -577,7 +577,7 @@
"props": {
"type": "button",
"disabled": "{{page?.data?.abilities?.can_update !== true}}",
"className": "inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
"className": "btn btn-primary"
},
"actions": [
{
@@ -648,14 +648,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 btn btn-secondary"
},
"responsive": {
"mobile": {
"props": {
"className": "flex-center justify-center p-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
}
}
"className": "btn btn-secondary"
},
"actions": [
{
@@ -691,14 +684,7 @@
"name": "Button",
"props": {
"disabled": "{{page?.data?.abilities?.can_update !== true}}",
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"mobile": {
"props": {
"className": "flex-center justify-center p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
}
}
"className": "btn btn-primary"
},
"actions": [
{
@@ -734,14 +720,7 @@
"name": "Button",
"props": {
"disabled": "{{page?.data?.abilities?.can_delete !== true}}",
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"responsive": {
"mobile": {
"props": {
"className": "flex-center justify-center p-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
}
}
"className": "btn btn-danger"
},
"actions": [
{
@@ -788,7 +767,7 @@
"name": "Div",
"if": "{{page?.data}}",
"props": {
"className": "flex-center gap-3 mb-4 px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
"className": "flex-center flex-wrap gap-3 mb-4 px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
},
"children": [
{
@@ -829,7 +808,7 @@
},
"props": {
"type": "button",
"className": "{{(_local.lang ?? $locale) === loc ? 'px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded' : 'px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 rounded'}}"
"className": "{{(_local.lang ?? $locale) === loc ? 'px-3 py-1 text-xs font-medium text-white bg-blue-600 dark:bg-blue-500 rounded' : 'px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 rounded'}}"
},
"actions": [
{
@@ -857,6 +836,13 @@
"props": {
"className": "text-hint ml-auto italic"
},
"responsive": {
"portable": {
"props": {
"className": "text-hint w-full italic"
}
}
},
"text": "$t:sirsoft-page.admin.page.detail.display_language_hint"
}
]
@@ -875,14 +861,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "grid-2col px-6 py-4 sm:grid-cols-4"
"className": "flex flex-col gap-3 pb-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center flex-wrap gap-3 mb-3"
"className": "flex-center flex-wrap gap-3"
},
"children": [
{
@@ -934,7 +920,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
}
}
]
@@ -989,7 +975,7 @@
"handler": "toast",
"params": {
"type": "error",
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
}
}
]
@@ -1073,18 +1059,21 @@
"type": "basic",
"name": "Div",
"props": {
"className": "panel-header-row"
"className": "grid-2col-responsive pt-4 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-table-header"
"className": "text-table-header shrink-0 w-20"
},
"text": "$t:sirsoft-page.admin.page.detail.info.creator"
},
@@ -1092,7 +1081,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-primary mt-1 text-sm"
"className": "text-primary text-sm"
},
"text": "{{page?.data?.creator?.name ?? '-'}}"
}
@@ -1101,12 +1090,15 @@
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-table-header"
"className": "text-table-header shrink-0 w-20"
},
"text": "$t:sirsoft-page.admin.page.detail.info.published_at"
},
@@ -1114,7 +1106,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-primary mt-1 text-sm"
"className": "text-primary text-sm"
},
"text": "{{page?.data?.published_at ?? '-'}}"
}
@@ -1123,12 +1115,15 @@
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-table-header"
"className": "text-table-header shrink-0 w-20"
},
"text": "$t:sirsoft-page.admin.page.detail.info.created_at"
},
@@ -1136,7 +1131,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-primary mt-1 text-sm"
"className": "text-primary text-sm"
},
"text": "{{page?.data?.created_at ?? '-'}}"
}
@@ -1145,12 +1140,15 @@
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-baseline gap-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-table-header"
"className": "text-table-header shrink-0 w-20"
},
"text": "$t:sirsoft-page.admin.page.detail.info.updated_at"
},
@@ -1158,7 +1156,7 @@
"type": "basic",
"name": "P",
"props": {
"className": "text-primary mt-1 text-sm"
"className": "text-primary text-sm"
},
"text": "{{page?.data?.updated_at ?? '-'}}"
}
@@ -1182,7 +1180,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 panel-header flex-between cursor-pointer select-none"
"className": "flex-between cursor-pointer select-none"
},
"actions": [
{
@@ -1199,7 +1197,7 @@
"type": "basic",
"name": "Span",
"props": {
"className": "text-body font-medium"
"className": "card-title"
},
"text": "$t:sirsoft-page.admin.page.detail.tabs.preview"
},
@@ -1261,7 +1259,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 panel-header flex-between cursor-pointer"
"className": "flex-between cursor-pointer"
},
"actions": [
{
@@ -1269,54 +1267,34 @@
"handler": "setState",
"params": {
"target": "local",
"showAttachments": "{{!_local.showAttachments}}"
"showAttachments": "{{!(_local.showAttachments ?? true)}}"
}
}
],
"children": [
{
"type": "basic",
"name": "Div",
"name": "Span",
"props": {
"className": "flex-center gap-2"
"className": "card-title"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "paperclip",
"className": "text-gray-500 dark:text-gray-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary text-sm font-semibold"
},
"text": "$t:sirsoft-page.admin.page.detail.attachments|count={{(page?.data?.attachments ?? []).length}}"
}
]
"text": "$t:sirsoft-page.admin.page.detail.attachments|count={{(page?.data?.attachments ?? []).length}}"
},
{
"type": "basic",
"name": "Icon",
"props": {
"name": "{{_local.showAttachments ? 'chevron-up' : 'chevron-down'}}",
"name": "{{(_local.showAttachments ?? true) ? 'chevron-up' : 'chevron-down'}}",
"className": "text-gray-400 dark:text-gray-500"
}
}
]
},
{
"id": "attachments_list",
"id": "attachments_list_{{att_idx}}",
"type": "basic",
"name": "Div",
"if": "{{_local.showAttachments}}",
"props": {
"className": "divide-y divide-gray-100 dark:divide-gray-700"
},
"if": "{{_local.showAttachments ?? true}}",
"iteration": {
"source": "page?.data?.attachments ?? []",
"item_var": "attachment_item",
@@ -1324,13 +1302,22 @@
},
"children": [
{
"id": "attachment_card",
"id": "attachment_card_{{att_idx}}",
"type": "basic",
"name": "A",
"name": "Div",
"props": {
"href": "{{attachment_item?.download_url}}",
"className": "flex-center group gap-3 px-4 py-2.5 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 transition-colors cursor-pointer no-underline"
"className": "flex-center group gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "downloadAttachment",
"params": {
"url": "{{attachment_item?.download_url}}",
"filename": "{{attachment_item?.original_filename}}"
}
}
],
"children": [
{
"comment": "이미지 썸네일 (이미지 파일인 경우)",
@@ -1400,7 +1387,7 @@
"type": "basic",
"name": "Icon",
"props": {
"name": "arrow-down-tray",
"name": "download",
"className": "text-gray-400 dark:text-gray-500 group-hover:text-blue-500 dark:text-blue-400 dark:group-hover:text-blue-400 transition-colors"
}
}
@@ -1424,14 +1411,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 panel-header"
"className": "pb-4"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body font-medium"
"className": "card-title"
},
"text": "$t:sirsoft-page.admin.page.form.seo.title"
}
@@ -1532,14 +1519,14 @@
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 panel-header"
"className": "pb-4"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-body font-medium"
"className": "card-title"
},
"text": "$t:sirsoft-page.admin.page.detail.tabs.versions"
}
@@ -1549,7 +1536,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-2 flex-center gap-2 text-info-soft bg-blue-50 dark:bg-blue-900/20"
"className": "mb-4 px-4 py-2.5 flex-center gap-2 rounded-md text-info-soft bg-blue-50 dark:bg-blue-900/20"
},
"children": [
{
@@ -1665,7 +1652,7 @@
"props": {
"className": "text-sm text-gray-600 dark:text-gray-400"
},
"text": "{{row.changes_summary?.changed_fields?.join(', ') ?? '-'}}"
"text": "{{row.changes_summary?.changed_fields?.map(f => $t('sirsoft-page.admin.page.detail.versions.field_labels.' + f)).join(', ') ?? '-'}}"
},
{
"type": "basic",
@@ -44,16 +44,35 @@
],
"init_actions": [
{
"comment": "생성 모드에서 폼 기본값 초기화 (published는 Select fallback과 동기화)",
"comment": "생성 모드: 직전 페이지(수정)에서 같은 레이아웃을 재사용하므로 _local.form 잔존을 먼저 제거 (form=null → deep merge 가 객체 대신 null 로 덮어써 이전 id/title/attachments 완전 삭제)",
"if": "{{!route?.id}}",
"handler": "setState",
"params": {
"target": "local",
"form": null
}
},
{
"comment": "생성 모드에서 폼 기본값 초기화 (published는 Select fallback과 동기화). 수정→등록 전환 시 이전 슬러그 검증 상태(slugChecked/slugAvailable 등)도 함께 리셋",
"if": "{{!route?.id}}",
"handler": "setState",
"params": {
"target": "local",
"form": {
"slug": "{{query.slug ?? ''}}",
"title": {},
"content": null,
"published": false,
"content_mode": "html"
}
"content_mode": "html",
"seo_meta": null,
"attachments": []
},
"slugChecked": false,
"slugAvailable": false,
"slugChecking": false,
"slugError": null,
"errors": null,
"hasChanges": false
}
},
{
@@ -102,7 +121,7 @@
"name": "Div",
"if": "{{_local.errors && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "alert-danger"
"className": "alert-danger mb-6"
},
"children": [
{
@@ -128,7 +147,7 @@
"type": "basic",
"name": "H3",
"props": {
"className": "text-sm font-mediumtext-danger-soft"
"className": "text-sm font-medium text-danger-soft"
},
"text": "$t:sirsoft-page.admin.page.form.validation_error"
},
@@ -406,7 +425,7 @@
"props": {
"className": "form-hint block"
},
"text": "$t:sirsoft-page.admin.page.form.fields.slug_hint|slug={{_local.form?.slug ?? 'example'}}"
"text": "$t:sirsoft-page.admin.page.form.fields.slug_hint|slug={{_local.form?.slug || 'example'}}"
},
{
"type": "basic",
@@ -693,7 +712,7 @@
"maxFiles": 5,
"maxSize": 10,
"accept": ".jpg,.jpeg,.png,.gif,.webp,.pdf,.zip",
"initialFiles": "{{pageData?.data?.attachments ?? []}}",
"initialFiles": "{{_local.form?.attachments ?? []}}",
"autoUpload": false,
"uploadTriggerEvent": "upload:page_attachments",
"disabled": "{{_computed.isReadOnly}}",
@@ -707,9 +726,47 @@
"apiEndpoints": {
"upload": "/api/modules/sirsoft-page/admin/attachments",
"delete": "/api/modules/sirsoft-page/admin/attachments/:id",
"reorder": "/api/modules/sirsoft-page/admin/attachments/reorder"
"reorder": "{{route?.id ? '/api/modules/sirsoft-page/admin/attachments/reorder' : ''}}"
}
}
},
"actions": [
{
"comment": "업로드 완료 시 form.attachments 에 누적 (수정 모드만 - 생성 모드는 저장 시 temp_key 로 연결)",
"event": "onUploadComplete",
"handler": "setState",
"params": {
"target": "local",
"form.attachments": "{{route?.id ? [...(_local.form?.attachments ?? []), ...($args[0] ?? [])] : (_local.form?.attachments ?? [])}}"
}
},
{
"comment": "드래그 순서 변경 시 form.attachments 를 최종 순서로 덮어쓰기 (이커머스 상품 이미지와 동일 패턴)",
"event": "onReorder",
"handler": "setState",
"params": {
"target": "local",
"form.attachments": "{{$args[0] ?? []}}"
}
},
{
"comment": "첨부 삭제 시 form.attachments 에서 제거",
"event": "onRemove",
"handler": "setState",
"params": {
"target": "local",
"form.attachments": "{{(_local.form?.attachments ?? []).filter(item => (item.hash || item.id) !== $args[0])}}"
}
},
{
"comment": "업로드 검증 실패(용량/확장자/개수 초과 등) 시 사용자에게 toast 안내",
"event": "onUploadError",
"handler": "toast",
"params": {
"type": "error",
"message": "{{$args[0]}}"
}
}
]
}
]
},
@@ -904,7 +961,10 @@
"actions": [
{
"type": "click",
"handler": "navigateBack"
"handler": "navigate",
"params": {
"path": "{{route?.id ? '/admin/pages/' + route.id : '/admin/pages'}}"
}
}
],
"children": [
@@ -923,7 +983,7 @@
"props": {
"type": "button",
"disabled": "{{_local.saving}}",
"className": "{{_local.saving ? 'px-4 py-2 bg-blue-400 text-white rounded-lg font-medium cursor-not-allowed' : 'px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors'}}"
"className": "btn btn-primary flex-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
@@ -966,7 +1026,7 @@
}
},
{
"comment": "FileUploader 업로드 트리거",
"comment": "FileUploader 업로드 트리거 (대기 중인 첨부를 temp_key 로 업로드 → 저장 시 백엔드가 연결)",
"handler": "emitEvent",
"params": {
"event": "upload:page_attachments"
@@ -1026,6 +1086,15 @@
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.saving}}",
"props": {
"name": "spinner",
"className": "spinner-sm"
}
},
{
"type": "basic",
"name": "Span",
@@ -121,7 +121,7 @@
"name": "Icon",
"props": {
"name": "refresh",
"className": "w-5 h-5"
"size": "sm"
}
}
]
@@ -131,7 +131,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-primary",
"disabled": "{{pages?.data?.abilities?.can_create !== true}}"
},
"actions": [
@@ -149,7 +149,7 @@
"name": "Icon",
"props": {
"name": "plus",
"className": "w-5 h-5"
"size": "sm"
}
},
{
@@ -250,7 +250,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-gray-800 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-500 transition-colors text-sm font-medium"
"className": "btn btn-primary"
},
"actions": [
{
@@ -323,7 +323,6 @@
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"searchField": "{{$event.target.value}}"
}
}
@@ -344,7 +343,6 @@
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"searchQuery": "{{$event.target.value}}"
}
},
@@ -373,7 +371,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "w-full px-4 bg-gray-800 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-500 transition-colors text-sm font-medium"
"className": "btn btn-primary w-full"
},
"actions": [
{
@@ -442,7 +440,7 @@
"name": "Select",
"props": {
"value": "{{query.published ?? ''}}",
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
"className": "w-32 px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"options": [
{
"label": "$t:sirsoft-page.admin.page.published_filter.all",
@@ -480,7 +478,7 @@
"name": "Select",
"props": {
"value": "{{query.sort_by ? (query.sort_by + '_' + (query.sort_order || 'desc')) : 'created_at_desc'}}",
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
"className": "w-32 px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"options": [
{
"label": "$t:sirsoft-page.admin.page.sort.created_at_desc",
@@ -523,7 +521,7 @@
"name": "Select",
"props": {
"value": "{{query.per_page || '20'}}",
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
"className": "w-20 px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"options": [
{
"label": "$t:sirsoft-page.admin.page.per_page|count=10",
@@ -902,7 +900,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-secondary",
"disabled": "{{_global.isBulkPublishing}}"
},
"actions": [
@@ -924,7 +922,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "flex-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-primary",
"disabled": "{{_global.isBulkPublishing}}"
},
"actions": [
@@ -1063,7 +1061,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-secondary",
"disabled": "{{_global.isBulkPublishing}}"
},
"actions": [
@@ -1085,7 +1083,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "flex-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-primary",
"disabled": "{{_global.isBulkPublishing}}"
},
"actions": [
@@ -1223,7 +1221,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-secondary",
"disabled": "{{_global.isDeletingPage}}"
},
"actions": [
@@ -1244,7 +1242,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "btn btn-danger",
"disabled": "{{_global.isDeletingPage}}"
},
"actions": [
@@ -0,0 +1,139 @@
<?php
namespace Modules\Sirsoft\Page\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageAttachment;
/**
* Playwright E2E 용 페이지 도메인 시드 커맨드.
*
* 미발행 페이지 관리자 미리보기 E2E 를 위해 고정 슬러그의 미발행/발행 페이지와
* 첨부를 발급하여 stdout 에 JSON 으로 반환한다. 재실행 시 동일 슬러그를 물리 삭제 후 재생성해
* 멱등성을 보장한다 (페이지·첨부는 hard delete 모델).
*
* 보안 가드 (코어 PlaywrightIssueToken 과 동일 3중 패턴):
* ① CLI 한정 — `php_sapi_name() === 'cli'`
* ② G7_PLAYWRIGHT_BYPASS=1 환경변수 옵트인
* ③ APP_DEBUG=true inline override — production + debug=false 환경에서도 동작
*
* 호출 예시:
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-page --attachments=2 --json
*/
class PlaywrightSeedPage extends Command
{
/**
* 미리보기 E2E 용 고정 슬러그 (spec 에서 URL 로 사용).
*/
private const UNPUBLISHED_SLUG = 'test-e2e-unpublished-preview';
private const PUBLISHED_SLUG = 'test-e2e-published-preview';
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'playwright:seed-page
{--attachments=2 : 페이지당 발급할 첨부 수}
{--json : 결과를 JSON 으로 출력}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = 'Playwright E2E 용 페이지 도메인 데이터 시드 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
// ① CLI 한정
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다.');
return self::FAILURE;
}
// ② 명시 옵트인
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다.');
return self::FAILURE;
}
// ③ APP_DEBUG 강제 — SettingsServiceProvider 의 bypass 분기가 settings JSON 덮어쓰기 차단 상태
Config::set('app.debug', true);
$attachmentsPerPage = max(1, (int) $this->option('attachments'));
$result = DB::transaction(function () use ($attachmentsPerPage) {
// 재실행 멱등: 고정 슬러그 페이지 물리 삭제 (첨부는 FK 로 함께 정리)
Page::whereIn('slug', [self::UNPUBLISHED_SLUG, self::PUBLISHED_SLUG])->get()
->each(function (Page $page) {
PageAttachment::where('page_id', $page->id)->delete();
$page->delete();
});
$author = User::factory()->create();
$unpublished = Page::factory()->create([
'slug' => self::UNPUBLISHED_SLUG,
'title' => ['ko' => '미발행 미리보기 대상', 'en' => 'Unpublished Preview Target'],
'content' => ['ko' => '<p>미발행 본문</p>', 'en' => '<p>Unpublished body</p>'],
'published' => false,
'published_at' => null,
'created_by' => $author->id,
'updated_by' => $author->id,
]);
$published = Page::factory()->published()->create([
'slug' => self::PUBLISHED_SLUG,
'title' => ['ko' => '발행 대조군', 'en' => 'Published Control'],
'content' => ['ko' => '<p>발행 본문</p>', 'en' => '<p>Published body</p>'],
'created_by' => $author->id,
'updated_by' => $author->id,
]);
$attachmentIds = [];
foreach ([$unpublished, $published] as $page) {
for ($i = 1; $i <= $attachmentsPerPage; $i++) {
// 첫 첨부는 이미지(미리보기 URL 생성 대상), 나머지는 일반 파일
$factory = PageAttachment::factory();
$factory = $i === 1 ? $factory->image() : $factory;
$attachment = $factory->create([
'page_id' => $page->id,
'order' => $i,
'created_by' => $author->id,
]);
$attachmentIds[] = $attachment->id;
}
}
return [
'unpublished_slug' => self::UNPUBLISHED_SLUG,
'published_slug' => self::PUBLISHED_SLUG,
'unpublished_id' => $unpublished->id,
'published_id' => $published->id,
'attachment_ids' => $attachmentIds,
];
});
if ($this->option('json')) {
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE));
} else {
$this->info('페이지 시드 완료: 미발행='.self::UNPUBLISHED_SLUG.' / 발행='.self::PUBLISHED_SLUG);
}
return self::SUCCESS;
}
}
@@ -3,17 +3,18 @@
namespace Modules\Sirsoft\Page\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Modules\Sirsoft\Page\Http\Requests\ReorderPageAttachmentsRequest;
use Modules\Sirsoft\Page\Http\Requests\UploadPageAttachmentRequest;
use Modules\Sirsoft\Page\Http\Resources\PageAttachmentResource;
use Modules\Sirsoft\Page\Services\PageAttachmentService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 관리자 페이지 첨부파일 컨트롤러
*
* 페이지 첨부파일 업로드/삭제/다운로드/순서변경을 처리합니다.
* 페이지 첨부파일 업로드/삭제/순서변경을 처리합니다.
* 파일 서빙(다운로드/미리보기)은 공개 hash 라우트(PublicPageAttachmentController)로 단일화됨.
*/
class PageAttachmentController extends AdminBaseController
{
@@ -71,7 +72,7 @@ class PageAttachmentController extends AdminBaseController
}
return $this->success('sirsoft-page::messages.attachment.delete_success');
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return $this->notFound('sirsoft-page::messages.attachment.not_found');
} catch (\Exception $e) {
return $this->error('sirsoft-page::messages.attachment.delete_failed', 500, $e->getMessage());
@@ -99,40 +100,4 @@ class PageAttachmentController extends AdminBaseController
return $this->error('sirsoft-page::messages.attachment.reorder_failed', 500, $e->getMessage());
}
}
/**
* 첨부파일을 다운로드합니다 (해시 기반).
*
* @param string $hash 첨부파일 해시 (12자)
* @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류
*/
public function download(string $hash): StreamedResponse|JsonResponse
{
$attachment = $this->attachmentService->getByHash($hash);
if (! $attachment) {
return $this->notFound('sirsoft-page::messages.attachment.not_found');
}
$response = $this->attachmentService->download($attachment);
return $response ?: $this->error('sirsoft-page::messages.attachment.file_not_found', 404);
}
/**
* 이미지 첨부파일을 미리봅니다 (해시 기반, inline).
*
* @param string $hash 첨부파일 해시 (12자)
* @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류
*/
public function preview(string $hash): StreamedResponse|JsonResponse
{
$attachment = $this->attachmentService->getByHash($hash);
if (! $attachment) {
return $this->notFound('sirsoft-page::messages.attachment.not_found');
}
$response = $this->attachmentService->preview($attachment);
return $response ?: $this->error('sirsoft-page::messages.attachment.file_not_found', 404);
}
}
@@ -44,7 +44,7 @@ class PageController extends AdminBaseController
{
try {
$validated = $request->validated();
$perPage = $validated['per_page'] ?? 15;
$perPage = $validated['per_page'] ?? 20;
$pages = $this->pageService->getPages(
array_filter($validated, fn ($v) => $v !== null),
$perPage
@@ -212,9 +212,12 @@ class PageController extends AdminBaseController
$validated = $request->validated();
$count = $this->pageService->bulkChangePublishStatus($validated['ids'], $validated['published']);
return $this->success('sirsoft-page::messages.page.bulk_publish_success', [
'count' => $count,
]);
return $this->success(
'sirsoft-page::messages.page.bulk_publish_success',
['count' => $count],
200,
['count' => $count],
);
} catch (\Exception $e) {
return $this->error('sirsoft-page::messages.page.bulk_publish_failed', 500, $e->getMessage());
}
@@ -2,15 +2,20 @@
namespace Modules\Sirsoft\Page\Http\Controllers\User;
use App\Enums\PermissionType;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Sirsoft\Page\Services\PageAttachmentService;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 공개 페이지 첨부파일 컨트롤러
*
* 발행된 페이지의 첨부파일 다운로드를 처리합니다.
* 첨부파일 서빙(다운로드/미리보기)을 처리합니다. 썸네일 <img>·다운로드는 브라우저 직접
* GET 이라 토큰을 실을 수 없으므로 공개 hash 라우트로 단일화합니다.
* - 미리보기(썸네일): 공개 서빙 (미발행 콘텐츠도 hash 보유 시 조회 가능 — 트레이드오프 수용)
* - 다운로드: 발행 첨부는 누구나, 미발행 첨부는 pages.read 관리자만 (파일 보호)
*/
class PublicPageAttachmentController extends PublicBaseController
{
@@ -21,20 +26,29 @@ class PublicPageAttachmentController extends PublicBaseController
/**
* 첨부파일을 다운로드합니다 (해시 기반).
*
* 발행된 페이지의 첨부파일만 다운로드 가능합니다.
* 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 페이지 조회 권한
* (sirsoft-page.pages.read) 관리자만 다운로드할 수 있습니다.
*
* @param Request $request HTTP 요청 객체 (다운로드 권한 판정용)
* @param string $hash 첨부파일 해시 (12자)
* @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류
*/
public function download(string $hash): StreamedResponse|JsonResponse
// audit:allow controller-base-request-injection reason: GET 파일 서빙. 인증 사용자만 read-only 참조($request->user())해 미발행 첨부 다운로드 권한을 판정. 검증할 body 없음(hash 는 라우트 파라미터)
public function download(Request $request, string $hash): StreamedResponse|JsonResponse
{
$attachment = $this->attachmentService->getByHash($hash);
if (! $attachment) {
return $this->notFound('sirsoft-page::messages.attachment.not_found');
}
// 발행된 페이지의 첨부파일만 다운로드 가능
if (! $attachment->page || ! $attachment->page->published) {
// 미발행 페이지의 첨부는 페이지 조회 권한 관리자만 다운로드 가능
$published = $attachment->page && $attachment->page->published;
$canReadUnpublished = $request->user()?->hasPermission(
'sirsoft-page.pages.read',
PermissionType::Admin
) ?? false;
if (! $published && ! $canReadUnpublished) {
return $this->notFound('sirsoft-page::messages.attachment.not_found');
}
@@ -46,7 +60,9 @@ class PublicPageAttachmentController extends PublicBaseController
/**
* 이미지 첨부파일을 미리봅니다 (해시 기반, inline).
*
* 발행된 페이지의 이미지 첨부파일만 미리보기 가능합니다.
* 썸네일 <img> 는 토큰을 실을 수 없으므로 발행/미발행 무관 공개 서빙합니다.
* 미발행 콘텐츠의 썸네일은 hash 를 보유해야만 조회 가능하며(비추측성), 파일
* 다운로드는 download() 의 권한 게이트로 보호됩니다.
*
* @param string $hash 첨부파일 해시 (12자)
* @return StreamedResponse|JsonResponse 파일 스트리밍 응답 또는 오류
@@ -58,11 +74,6 @@ class PublicPageAttachmentController extends PublicBaseController
return $this->notFound('sirsoft-page::messages.attachment.not_found');
}
// 발행된 페이지의 첨부파일만 미리보기 가능
if (! $attachment->page || ! $attachment->page->published) {
return $this->notFound('sirsoft-page::messages.attachment.not_found');
}
$response = $this->attachmentService->preview($attachment);
return $response ?: $this->error('sirsoft-page::messages.attachment.file_not_found', 404);
@@ -2,8 +2,10 @@
namespace Modules\Sirsoft\Page\Http\Controllers\User;
use App\Enums\PermissionType;
use App\Http\Controllers\Api\Base\PublicBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Sirsoft\Page\Http\Resources\PublicPageResource;
use Modules\Sirsoft\Page\Services\PageService;
@@ -29,13 +31,22 @@ class PublicPageController extends PublicBaseController
/**
* 슬러그로 발행된 페이지를 조회합니다.
*
* 페이지 조회 권한(sirsoft-page.pages.read)을 가진 관리자는 미발행 페이지도
* 사용자 화면에서 미리볼 수 있습니다(비로그인·일반 회원은 미발행 시 404 유지).
*
* @param Request $request HTTP 요청
* @param string $slug 페이지 슬러그
* @return JsonResponse 페이지 상세 응답
*/
public function show(string $slug): JsonResponse
// audit:allow controller-base-request-injection reason: GET 상세 조회. 인증 사용자만 read-only 참조($request->user())해 미리보기 권한 판정. 검증할 body 없음(slug 는 라우트 파라미터)
public function show(Request $request, string $slug): JsonResponse
{
try {
$page = $this->pageService->getPublishedPageBySlug($slug);
$user = $request->user();
$canPreview = $user
&& $user->hasPermission('sirsoft-page.pages.read', PermissionType::Admin);
$page = $this->pageService->getPublishedPageBySlug($slug, $canPreview);
if (! $page) {
return $this->notFound('sirsoft-page::messages.page.not_found');
@@ -43,9 +54,11 @@ class PublicPageController extends PublicBaseController
$page->load('attachments');
$isPreview = $canPreview && ! $page->published;
return $this->successWithResource(
'sirsoft-page::messages.page.fetch_success',
new PublicPageResource($page)
(new PublicPageResource($page))->withPreview($isPreview)
);
} catch (\Exception $e) {
return $this->error('sirsoft-page::messages.page.fetch_failed', 500, $e->getMessage());
@@ -11,6 +11,8 @@ class PageListRequest extends FormRequest
{
/**
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (실제 인가는 미들웨어 담당)
*/
public function authorize(): bool
{
@@ -26,11 +28,13 @@ class PageListRequest extends FormRequest
{
return [
'published' => ['nullable', 'boolean'],
'search' => ['nullable', 'string', 'max:255'],
// 공백 없는 긴 한글(약 140자+)은 FULLTEXT phrase 토큰 한도를 초과해
// 'Too many words in a FTS phrase'(191) → 500 을 유발하므로 100자로 제한한다.
'search' => ['nullable', 'string', 'max:100'],
'search_field' => ['nullable', 'string', 'in:all,title,slug'],
'filters' => ['nullable', 'array'],
'filters.*.field' => ['nullable', 'string', 'in:all,title,slug'],
'filters.*.value' => ['nullable', 'string', 'max:255'],
'filters.*.value' => ['nullable', 'string', 'max:100'],
'filters.*.operator' => ['nullable', 'string', 'in:like,eq,starts_with,ends_with'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
@@ -57,4 +61,20 @@ class PageListRequest extends FormRequest
'sort_order.in' => __('sirsoft-page::validation.sort_order.in'),
];
}
/**
* 검증 오류에 사용할 사용자 친화적 필드명
*
* 레이아웃은 검색어를 filters[0][value] 로 전송하므로, 내부 필드명이 그대로
* 노출되지 않도록 '검색어' 로 매핑한다. (search 파라미터 경로도 동일하게 통일)
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'search' => __('sirsoft-page::validation.attributes.search'),
'filters.*.value' => __('sirsoft-page::validation.attributes.search'),
];
}
}
@@ -15,6 +15,8 @@ class StorePageRequest extends FormRequest
{
/**
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 체인에서 검증)
*/
public function authorize(): bool
{
@@ -15,6 +15,8 @@ class UpdatePageRequest extends FormRequest
{
/**
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true (권한은 미들웨어 체인에서 검증)
*/
public function authorize(): bool
{
@@ -4,12 +4,27 @@ namespace Modules\Sirsoft\Page\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\Sirsoft\Page\Models\PageAttachment;
/**
* 페이지 첨부파일 API 리소스
*/
class PageAttachmentResource extends BaseApiResource
{
/**
* 첨부파일 목록을 리소스 배열로 변환합니다.
*
* @param iterable<int, PageAttachment>|null $attachments 첨부파일 목록
* @return array<int, self>
*/
public static function collectionFor($attachments): array
{
return Collection::make($attachments ?? [])
->map(fn ($attachment) => new self($attachment))
->all();
}
/**
* 리소스를 배열로 변환합니다.
*
@@ -27,8 +42,8 @@ class PageAttachmentResource extends BaseApiResource
'collection' => $this->collection,
'order' => $this->order,
'is_image' => $this->isImage(),
'download_url' => $this->download_url,
'preview_url' => $this->preview_url,
'download_url' => $this->resource->downloadUrl(),
'preview_url' => $this->resource->previewUrl(),
'created_at' => $this->created_at
? $this->formatDateTimeStringForUser($this->created_at)
: null,
@@ -38,7 +38,10 @@ class PageResource extends BaseApiResource
'uuid' => $this->updater->uuid,
'name' => $this->updater->name,
]),
'attachments' => PageAttachmentResource::collection($this->whenLoaded('attachments')),
'attachments' => $this->whenLoaded(
'attachments',
fn () => PageAttachmentResource::collectionFor($this->attachments)
),
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
@@ -13,6 +13,27 @@ use Illuminate\Http\Request;
*/
class PublicPageResource extends BaseApiResource
{
/**
* 미발행 페이지 관리자 미리보기 여부.
*
* true 이면 첨부 URL을 발행 가드가 없는 'admin' 컨텍스트로 생성하여
* 관리자가 미발행 페이지의 첨부까지 미리볼 수 있도록 합니다.
*/
protected bool $preview = false;
/**
* 미리보기(미발행 관리자 조회) 여부를 설정합니다.
*
* @param bool $preview 미리보기 여부
* @return $this
*/
public function withPreview(bool $preview): self
{
$this->preview = $preview;
return $this;
}
/**
* 리소스를 배열로 변환합니다.
*
@@ -32,12 +53,16 @@ class PublicPageResource extends BaseApiResource
? ($this->content[$locale] ?? $this->content[$fallback] ?? (! empty($this->content) ? array_values($this->content)[0] : ''))
: (string) ($this->content ?? ''),
'content_mode' => $this->content_mode ?? 'html',
'is_preview' => $this->preview,
'published_at' => $this->published_at
? $this->formatDateTimeStringForUser($this->published_at)
: null,
'seo_meta' => $this->seo_meta,
'current_version' => $this->current_version,
'attachments' => PageAttachmentResource::collection($this->whenLoaded('attachments')),
'attachments' => $this->whenLoaded(
'attachments',
fn () => PageAttachmentResource::collectionFor($this->attachments)
),
'created_at' => $this->created_at
? $this->formatDateTimeStringForUser($this->created_at)
: null,
@@ -35,6 +35,11 @@ class SeoPageCacheListener implements HookListenerInterface
'method' => 'onPageChange',
'priority' => 20,
],
// 버전 복원도 콘텐츠(title/content/seo_meta) 변경이므로 수정과 동일하게 무효화
'sirsoft-page.page.after_restore' => [
'method' => 'onPageChange',
'priority' => 20,
],
];
}
@@ -62,9 +67,9 @@ class SeoPageCacheListener implements HookListenerInterface
try {
$cache = app(SeoCacheManagerInterface::class);
// 페이지 상세 URL 캐시 무효화
// 페이지 상세 URL 캐시 무효화 (공개 URL은 단수형 /page/{slug})
if ($page && isset($page->slug)) {
$cache->invalidateByUrl("*/pages/{$page->slug}");
$cache->invalidateByUrl("*/page/{$page->slug}");
}
// 페이지 상세 레이아웃 캐시 무효화
@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
use Modules\Sirsoft\Page\Database\Factories\PageFactory;
@@ -19,7 +18,6 @@ class Page extends Model implements FulltextSearchable
{
use HasFactory;
use Searchable;
use SoftDeletes;
/** @var array<string, array> 활동 로그 추적 필드 */
public static array $activityLogFields = [
@@ -6,13 +6,11 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Modules\Sirsoft\Page\Database\Factories\PageAttachmentFactory;
class PageAttachment extends Model
{
use HasFactory;
use SoftDeletes;
/**
* 팩토리 클래스를 반환합니다.
@@ -124,21 +122,45 @@ class PageAttachment extends Model
}
/**
* 다운로드 URL을 반환합니다.
* 다운로드 URL을 반환합니다 (공개 hash 라우트).
*
* @return string 다운로드 URL
*/
public function getDownloadUrlAttribute(): string
{
return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash;
return $this->downloadUrl();
}
/**
* 이미지 미리보기 URL을 반환합니다.
* 이미지 미리보기 URL을 반환합니다 (공개 hash 라우트).
*
* @return string|null 미리보기 URL (이미지가 아니면 null)
*/
public function getPreviewUrlAttribute(): ?string
{
return $this->previewUrl();
}
/**
* 다운로드 URL을 반환합니다.
*
* 썸네일 <img>·다운로드는 브라우저 직접 GET 이라 토큰을 실을 수 없으므로,
* 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화한다.
* 미발행 콘텐츠 다운로드 차단은 공개 라우트 내부의 권한 게이트가 담당한다.
*
* @return string 다운로드 URL
*/
public function downloadUrl(): string
{
return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash;
}
/**
* 이미지 미리보기 URL을 반환합니다 (공개 hash 라우트).
*
* @return string|null 미리보기 URL (이미지가 아니면 null)
*/
public function previewUrl(): ?string
{
if (! $this->isImage()) {
return null;
@@ -3,12 +3,15 @@
namespace Modules\Sirsoft\Page\Providers;
use App\Extension\BaseModuleServiceProvider;
use App\Seo\SitemapGenerator;
use Modules\Sirsoft\Page\Console\Commands\PlaywrightSeedPage;
use Modules\Sirsoft\Page\Repositories\Contracts\PageAttachmentRepositoryInterface;
use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface;
use Modules\Sirsoft\Page\Repositories\PageAttachmentRepository;
use Modules\Sirsoft\Page\Repositories\PageRepository;
use Modules\Sirsoft\Page\Repositories\PageVersionRepository;
use Modules\Sirsoft\Page\Seo\PageSitemapContributor;
use Modules\Sirsoft\Page\Services\PageAttachmentService;
/**
@@ -20,8 +23,6 @@ class PageServiceProvider extends BaseModuleServiceProvider
{
/**
* 모듈 식별자
*
* @var string
*/
protected string $moduleIdentifier = 'sirsoft-page';
@@ -47,18 +48,23 @@ class PageServiceProvider extends BaseModuleServiceProvider
/**
* 서비스 부트스트랩
*
* @return void
*/
public function boot(): void
{
parent::boot();
// Artisan 커맨드 등록 (콘솔 환경에서만)
if ($this->app->runningInConsole()) {
$this->commands([
PlaywrightSeedPage::class,
]);
}
// Sitemap 기여자 등록
$this->app->booted(function () {
if ($this->app->bound(\App\Seo\SitemapGenerator::class)) {
$this->app->make(\App\Seo\SitemapGenerator::class)->registerContributor(
new \Modules\Sirsoft\Page\Seo\PageSitemapContributor()
if ($this->app->bound(SitemapGenerator::class)) {
$this->app->make(SitemapGenerator::class)->registerContributor(
new PageSitemapContributor
);
}
});
@@ -17,7 +17,7 @@ interface PageRepositoryInterface
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지 목록
*/
public function paginate(array $filters = [], int $perPage = 15): LengthAwarePaginator;
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
/**
* ID로 페이지를 조회합니다.
@@ -97,13 +97,4 @@ interface PageRepositoryInterface
* @return int 일치하는 페이지 수
*/
public function countByKeyword(string $keyword): int;
/**
* 여러 페이지의 발행 상태를 일괄 변경합니다.
*
* @param array $ids 페이지 ID 목록
* @param array $data 업데이트할 데이터 (published, updated_by 등)
* @return int 변경된 페이지 수
*/
public function bulkUpdatePublished(array $ids, array $data): int;
}
@@ -5,7 +5,9 @@ namespace Modules\Sirsoft\Page\Repositories;
use App\Helpers\PermissionHelper;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
@@ -26,35 +28,17 @@ class PageRepository implements PageRepositoryInterface
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지 목록
*/
public function paginate(array $filters = [], int $perPage = 15): LengthAwarePaginator
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
// filters 배열에서 search/search_field 변환
$filters = $this->normalizeSearchFilters($filters);
// FULLTEXT 대상 검색인 경우 Scout 사용
if (! empty($filters['search'])) {
$searchField = $filters['search_field'] ?? 'all';
if (in_array($searchField, ['all', 'title'])) {
return Page::search($filters['search'])
->query(function ($query) use ($filters) {
$query->with(['creator', 'updater']);
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($query, 'sirsoft-page.pages.read');
// 발행 상태 필터
if (isset($filters['published']) && $filters['published'] !== '') {
$query->where('published', (bool) $filters['published']);
}
// 정렬
$this->applySorting($query, $filters);
})
->paginate($perPage);
}
}
// 검색 필드(title/slug/all)별로 대상 컬럼을 정확히 분리하기 위해
// 모든 검색을 applyFilters 통합 쿼리로 처리한다.
// - title: 제목만 (Scout searchableColumns 는 title+content 를 함께 검색하므로 사용하지 않음)
// - slug : 슬러그만
// - all : 제목 + 슬러그 (본문 content 은 검색 대상 아님 — UI '제목 또는 슬러그로 검색')
// (Scout 콜백에 slug orWhere 를 넣으면 total 카운트가 부풀려지는 회귀가 있어 #225 에서 제거됨)
$query = Page::with(['creator', 'updater']);
// 권한 스코프 필터링
@@ -94,7 +78,7 @@ class PageRepository implements PageRepositoryInterface
* @param int $id 페이지 ID
* @return Page 페이지 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function findOrFail(int $id): Page
{
@@ -202,9 +186,8 @@ class PageRepository implements PageRepositoryInterface
* (Scout 엔진은 indexing 및 다른 검색에서 계속 사용됨).
*
* @param string $keyword 검색 키워드
* @return \Illuminate\Database\Eloquent\Builder
*/
private function buildKeywordQuery(string $keyword): \Illuminate\Database\Eloquent\Builder
private function buildKeywordQuery(string $keyword): Builder
{
return Page::query()
->published()
@@ -218,7 +201,7 @@ class PageRepository implements PageRepositoryInterface
/**
* 쿼리에 정렬을 적용합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param Builder $query 쿼리 빌더
* @param array $filters 필터 조건 (sort_by, sort_order 포함)
*/
private function applySorting($query, array $filters): void
@@ -236,7 +219,7 @@ class PageRepository implements PageRepositoryInterface
/**
* 쿼리에 필터를 적용합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param Builder $query 쿼리 빌더
* @param array $filters 필터 조건
*/
private function applyFilters($query, array $filters): void
@@ -255,12 +238,12 @@ class PageRepository implements PageRepositoryInterface
$query->where('slug', 'like', "%{$keyword}%");
} elseif ($searchField === 'title') {
$query->where(function ($q) use ($keyword) {
$this->applyTitleKeywordSearch($q, $keyword, titleOnly: true);
$this->applyTitleKeywordSearch($q, $keyword, includeSlug: false);
});
} else {
// all: slug + title 통합 검색
// all: 제목 + 슬러그 통합 검색 (본문 content 은 검색 대상 아님 — UI '제목 또는 슬러그로 검색')
$query->where(function ($q) use ($keyword) {
$this->applyTitleKeywordSearch($q, $keyword);
$this->applyTitleKeywordSearch($q, $keyword, includeSlug: true);
});
}
}
@@ -269,36 +252,21 @@ class PageRepository implements PageRepositoryInterface
/**
* 제목(JSON) + 슬러그 키워드 검색을 쿼리에 적용합니다.
*
* title은 {"ko": "...", "en": "..."} 형태의 JSON 컬럼이므로
* JSON_UNQUOTE + LIKE 방식으로 각 로케일 값을 검색합니다.
* title은 {"ko": "...", "en": "..."} 형태의 JSON 컬럼이므로 FULLTEXT 로 검색한다.
* 검색 대상은 제목·슬러그이며 본문(content)은 포함하지 않는다
* (검색 UI 안내 '제목 또는 슬러그로 검색' 및 검색 필드 옵션 전체/제목/슬러그와 일치).
*
* @param \Illuminate\Database\Eloquent\Builder $query 쿼리 빌더
* @param Builder $query 쿼리 빌더
* @param string $keyword 검색 키워드
* @param bool $titleOnly 제목만 검색 여부 (false 시 slug도 포함)
* @param bool $includeSlug 슬러그도 함께 검색할지 여부 (전체 검색 시 true)
*/
private function applyTitleKeywordSearch($query, string $keyword, bool $titleOnly = false): void
private function applyTitleKeywordSearch($query, string $keyword, bool $includeSlug = false): void
{
if (! $titleOnly) {
if ($includeSlug) {
$query->where('slug', 'like', "%{$keyword}%");
}
DatabaseFulltextEngine::whereFulltext($query, 'title', $keyword, 'or');
if (! $titleOnly) {
DatabaseFulltextEngine::whereFulltext($query, 'content', $keyword, 'or');
}
}
/**
* 여러 페이지의 발행 상태를 일괄 변경합니다.
*
* @param array $ids 페이지 ID 목록
* @param array $data 업데이트할 데이터 (published, updated_by 등)
* @return int 변경된 페이지 수
*/
public function bulkUpdatePublished(array $ids, array $data): int
{
return Page::whereIn('id', $ids)->update($data);
}
/**
@@ -92,6 +92,9 @@ class PageAttachmentService
/**
* 임시 첨부파일을 페이지에 연결하고 파일을 이동합니다.
*
* getByTempKey 가 order(업로드 시점 부여) 순으로 반환하므로, 그 순서대로
* order 를 1..N 으로 재부여한다 (이커머스 ProductImageService::linkTempImages 와 동일).
*
* @param string $tempKey 임시 키
* @param int $pageId 연결할 페이지 ID
* @return int 연결된 첨부파일 수
@@ -101,7 +104,7 @@ class PageAttachmentService
$tempAttachments = $this->attachmentRepository->getByTempKey($tempKey);
$linkedCount = 0;
foreach ($tempAttachments as $attachment) {
foreach ($tempAttachments as $index => $attachment) {
// 최종 경로 생성
$newPath = date('Y/m/d').'/'.$attachment->stored_filename;
@@ -112,12 +115,14 @@ class PageAttachmentService
$this->storage->delete('attachments', $attachment->path);
}
// DB 업데이트: page_id 설정, temp_key 제거, path 변경
// DB 업데이트: page_id 설정, temp_key 제거, path 변경, order 재배치 (조회 순서 = 업로드 순서)
$this->attachmentRepository->update($attachment, [
'page_id' => $pageId,
'temp_key' => null,
'path' => $newPath,
'order' => $index + 1,
]);
$linkedCount++;
}
@@ -4,10 +4,13 @@ namespace Modules\Sirsoft\Page\Services;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface;
use Modules\Sirsoft\Page\Repositories\Contracts\PageVersionRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -32,7 +35,7 @@ class PageService
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지 목록
*/
public function getPages(array $filters = [], int $perPage = 15): LengthAwarePaginator
public function getPages(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$filters = HookManager::applyFilters('sirsoft-page.page.filter_list_query', $filters);
@@ -72,9 +75,12 @@ class PageService
// 버전 1 스냅샷 저장
$this->saveVersionSnapshot($page, $userId);
// temp_key 첨부파일 연결 및 파일 이동
// temp_key 첨부파일 연결 및 파일 이동 (업로드 순서대로 order 부여)
if (! empty($data['temp_key'])) {
$this->pageAttachmentService->linkTempAttachmentsWithMove($data['temp_key'], $page->id);
$this->pageAttachmentService->linkTempAttachmentsWithMove(
$data['temp_key'],
$page->id
);
}
HookManager::doAction('sirsoft-page.page.after_create', $page, $data);
@@ -122,9 +128,12 @@ class PageService
// 버전 스냅샷 저장
$this->saveVersionSnapshot($page, $userId);
// temp_key 첨부파일 연결 및 파일 이동
// temp_key 첨부파일 연결 및 파일 이동 (업로드 순서대로 order 부여)
if (! empty($data['temp_key'])) {
$this->pageAttachmentService->linkTempAttachmentsWithMove($data['temp_key'], $page->id);
$this->pageAttachmentService->linkTempAttachmentsWithMove(
$data['temp_key'],
$page->id
);
}
HookManager::doAction('sirsoft-page.page.after_update', $page, $data, $snapshot);
@@ -205,16 +214,40 @@ class PageService
*/
public function bulkChangePublishStatus(array $ids, bool $published): int
{
$updateData = [
'published' => $published,
'updated_by' => Auth::id(),
];
if ($published) {
$updateData['published_at'] = now();
if (empty($ids)) {
return 0;
}
return $this->pageRepository->bulkUpdatePublished($ids, $updateData);
return DB::transaction(function () use ($ids, $published) {
$userId = Auth::id();
$count = 0;
foreach ($ids as $id) {
// 존재하지 않는 페이지가 섞이면 예외 → 트랜잭션 전체 롤백 (all-or-nothing)
$page = $this->pageRepository->findOrFail((int) $id);
HookManager::doAction('sirsoft-page.page.before_publish', $page, $published);
$updateData = [
'published' => $published,
'updated_by' => $userId,
];
// 발행 시 published_at 갱신
if ($published) {
$updateData['published_at'] = now();
}
$page = $this->pageRepository->update($page, $updateData);
// 페이지별 after_publish 발화 → 활동로그 per-item 기록
HookManager::doAction('sirsoft-page.page.after_publish', $page, $published);
$count++;
}
return $count;
});
}
/**
@@ -270,7 +303,7 @@ class PageService
* @param int $id 페이지 ID
* @return Page 페이지 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function getPage(int $id): Page
{
@@ -286,23 +319,32 @@ class PageService
/**
* 슬러그로 발행된 페이지를 조회합니다.
*
* $allowUnpublished 가 true 이면 미발행 페이지도 반환합니다. 관리자 미리보기
* (사용자 화면에서 발행 전 페이지 확인)를 위한 것으로, 권한 판정은 호출부(컨트롤러)가
* 담당하고 이 메서드는 허용 여부만 플래그로 전달받습니다.
*
* @param string $slug 페이지 슬러그
* @return Page|null 발행된 페이지 모델 또는 null
* @param bool $allowUnpublished 미발행 페이지 반환 허용 여부 (관리자 미리보기)
* @return Page|null 페이지 모델 또는 null
*/
public function getPublishedPageBySlug(string $slug): ?Page
public function getPublishedPageBySlug(string $slug, bool $allowUnpublished = false): ?Page
{
$page = $this->pageRepository->findBySlug($slug);
return ($page && $page->published) ? $page : null;
if (! $page) {
return null;
}
return ($page->published || $allowUnpublished) ? $page : null;
}
/**
* 페이지 버전 이력을 조회합니다.
*
* @param Page $page 페이지 모델
* @return \Illuminate\Database\Eloquent\Collection 버전 목록 (최신순)
* @return Collection 버전 목록 (최신순)
*/
public function getVersions(Page $page): \Illuminate\Database\Eloquent\Collection
public function getVersions(Page $page): Collection
{
return $this->pageVersionRepository->getVersionsByPage($page);
}
@@ -311,11 +353,11 @@ class PageService
* 버전 ID로 페이지 버전을 조회합니다.
*
* @param int $versionId 버전 ID
* @return \Modules\Sirsoft\Page\Models\PageVersion 버전 모델
* @return PageVersion 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws ModelNotFoundException
*/
public function getVersion(int $versionId): \Modules\Sirsoft\Page\Models\PageVersion
public function getVersion(int $versionId): PageVersion
{
return $this->pageVersionRepository->findOrFail($versionId);
}
@@ -339,7 +381,7 @@ class PageService
* @param string $orderBy 정렬 컬럼
* @param string $direction 정렬 방향 (asc, desc)
* @param int $limit 조회할 최대 항목 수
* @return array{total: int, items: \Illuminate\Database\Eloquent\Collection}
* @return array{total: int, items: Collection}
*/
public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array
{
@@ -59,6 +59,11 @@ return [
'max' => 'The search query must not exceed :max characters.',
],
// user-friendly attribute names shown in validation errors
'attributes' => [
'search' => 'search query',
],
// search_field validation messages
'search_field' => [
'in' => 'The search field must be one of: all, title, slug.',
@@ -59,6 +59,11 @@ return [
'max' => '검색어는 최대 :max자까지 입력 가능합니다.',
],
// 검증 오류에 표시할 사용자 친화적 필드명
'attributes' => [
'search' => '검색어',
],
// search_field 검증 메시지
'search_field' => [
'in' => '검색 필드는 all, title, slug 중 하나여야 합니다.',
@@ -105,18 +105,6 @@ Route::prefix('admin/attachments')->middleware(['auth:sanctum', 'throttle:600,1'
->middleware('permission:admin,sirsoft-page.pages.update')
->name('reorder');
// 첨부파일 다운로드 (해시 기반)
Route::get('/download/{hash}', [PageAttachmentController::class, 'download'])
->middleware('permission:admin,sirsoft-page.pages.read')
->where('hash', '[a-zA-Z0-9]{12}')
->name('download');
// 첨부파일 이미지 미리보기 (해시 기반, inline)
Route::get('/preview/{hash}', [PageAttachmentController::class, 'preview'])
->middleware('permission:admin,sirsoft-page.pages.read')
->where('hash', '[a-zA-Z0-9]{12}')
->name('preview');
// 첨부파일 삭제
Route::delete('/{id}', [PageAttachmentController::class, 'destroy'])
->middleware('permission:admin,sirsoft-page.pages.update')
@@ -134,12 +122,12 @@ Route::prefix('admin/attachments')->middleware(['auth:sanctum', 'throttle:600,1'
*/
// optional.sanctum: Bearer 토큰이 있으면 인증, 없으면 guest로 통과
Route::prefix('pages')->middleware(['optional.sanctum', 'throttle:600,1'])->name('pages.')->group(function () {
// 공개 첨부파일 다운로드 (해시 기반) - /{slug} 보다 먼저 등록
// 첨부 다운로드 (해시 기반, 발행 상태에 따라 컨트롤러에서 접근 제어) - /{slug} 보다 먼저 등록
Route::get('/attachment/{hash}', [PublicPageAttachmentController::class, 'download'])
->where('hash', '[a-zA-Z0-9]{12}')
->name('attachment.download');
// 공개 첨부파일 이미지 미리보기 (해시 기반, inline)
// 첨부 이미지 미리보기 (공개 - 토큰 없는 <img> 직접 GET 을 위해 권한 체크 없이 이미지만 제공)
Route::get('/attachment/{hash}/preview', [PublicPageAttachmentController::class, 'preview'])
->where('hash', '[a-zA-Z0-9]{12}')
->name('attachment.preview');
@@ -174,7 +174,7 @@ class PageAttachmentControllerTest extends FeatureTestCase
$response->assertStatus(200)
->assertJsonPath('success', true);
$this->assertSoftDeleted('page_attachments', ['id' => $attachment->id]);
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
}
/**
@@ -258,29 +258,9 @@ class PageAttachmentControllerTest extends FeatureTestCase
->assertJsonValidationErrors(['order']);
}
// ─── 다운로드/미리보기 ─────────────────────────────
/**
* 존재하지 않는 해시로 다운로드 시 404를 반환하는지 확인
*/
public function test_download_nonexistent_hash_returns_404(): void
{
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/attachments/download/abcdefghijkl');
$response->assertStatus(404);
}
/**
* 존재하지 않는 해시로 미리보기 시 404를 반환하는지 확인
*/
public function test_preview_nonexistent_hash_returns_404(): void
{
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/attachments/preview/abcdefghijkl');
$response->assertStatus(404);
}
// 첨부 다운로드/미리보기는 공개 hash 라우트(pages/attachment/*)로 단일화되어
// 관리자 전용 preview/download 라우트가 제거됨. 해당 서빙 경로 테스트는
// PublicPageControllerTest 의 preview/download 매트릭스로 이관.
// ─── 인증 차단 ─────────────────────────────────────
@@ -11,26 +11,12 @@ use App\Helpers\PermissionHelper;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Search\Engines\DatabaseFulltextEngine;
use Laravel\Scout\EngineManager;
use App\Seo\Contracts\SeoCacheManagerInterface;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageAttachment;
use Modules\Sirsoft\Page\Models\PageVersion;
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
/**
* LIKE fallback 전용 Scout 엔진
*
* MySQL FULLTEXT는 트랜잭션 내 미커밋 데이터를 인덱싱하지 않으므로,
* 테스트에서 Scout 파이프라인 전체를 검증하기 위해 LIKE fallback을 강제합니다.
*/
class LikeFallbackEngine extends DatabaseFulltextEngine
{
public static function supportsFulltext(): bool
{
return false;
}
}
/**
* 관리자 페이지 관리 API 테스트
*
@@ -122,12 +108,13 @@ class PageControllerTest extends FeatureTestCase
/**
* 검색어로 목록을 조회할 수 있는지 확인
*
* 검색 동작은 슬러그(LIKE) 매칭으로 검증한다 — 슬러그 검색은 InnoDB FULLTEXT 가
* 트랜잭션 내 미커밋 데이터를 보장하지 않는 가시성 문제와 무관하게 안정적이다.
* (제목/본문 FULLTEXT 컬럼 범위 분리는 test_admin_search_title_excludes_content 가 담당)
*/
public function test_admin_can_search_pages(): void
{
// Scout 엔진을 LIKE fallback으로 교체 (트랜잭션 호환)
$this->swapScoutEngineToLikeFallback();
Page::factory()->create([
'slug' => 'test-search-target',
'title' => ['ko' => '검색대상페이지', 'en' => 'Search Target'],
@@ -135,11 +122,158 @@ class PageControllerTest extends FeatureTestCase
'updated_by' => $this->adminUser->id,
]);
// 전체(all) 검색에 슬러그 토큰을 넣으면 결과에 포함되어야 한다
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=검색대상');
->getJson('/api/modules/sirsoft-page/admin/pages?search=search-target&search_field=all');
$response->assertStatus(200);
$this->assertGreaterThanOrEqual(1, $response->json('data.meta.total'));
$slugs = collect($response->json('data.data'))->pluck('slug')->all();
$this->assertContains('test-search-target', $slugs);
}
/**
* 검색조건 '전체'(all)로 슬러그를 검색하면 결과에 포함되는지 확인
*
* 회귀: 기존에는 all 검색이 Scout(제목·본문 FULLTEXT) 경로로만 처리되어
* 슬러그가 검색 범위에서 누락됨. all 검색에도 슬러그 LIKE 가 포함되어야 한다.
*
* 슬러그 매칭은 LIKE 기반이라 테스트 트랜잭션의 미커밋 데이터도 검증 가능하다.
* (제목·본문 FULLTEXT 매칭은 별도 title 단독 검색 테스트가 담당)
*/
public function test_admin_search_all_includes_slug(): void
{
// 제목/본문에 없는 고유 슬러그 토큰 (운영 시더 데이터와 충돌 회피)
Page::factory()->create([
'slug' => 'test-zzslugonly-target',
'title' => ['ko' => '제목없음표제', 'en' => 'Heading Only'],
'content' => ['ko' => '본문내용', 'en' => 'Body'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
// search_field 미지정 = all. 제목/본문에 없는 슬러그 토큰으로 검색
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zzslugonly');
$response->assertStatus(200);
$slugs = collect($response->json('data.data'))->pluck('slug')->all();
$this->assertContains('test-zzslugonly-target', $slugs);
}
/**
* '전체'(all) 검색 결과의 total 이 실제 행 수와 일치하는지 확인 (#225 회귀 가드)
*
* 과거 Scout 콜백 내 orWhere('slug') 가 total 카운트를 부풀렸던 회귀(#225)를
* all 검색 통합 후에도 재발하지 않는지 검증한다.
* 고유 슬러그 토큰으로 결과를 1건으로 한정해 total ↔ 행 수 정합을 본다.
*/
public function test_admin_search_all_total_matches_rows(): void
{
Page::factory()->create([
'slug' => 'test-zztotalguard-one',
'title' => ['ko' => '집계가드대상', 'en' => 'Count Guard'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zztotalguard');
$response->assertStatus(200);
$total = $response->json('data.meta.total');
$rowCount = count($response->json('data.data'));
// 단일 페이지 결과: total 과 실제 반환 행 수가 일치해야 함 (부풀림 없음)
$this->assertSame(1, $total);
$this->assertSame(1, $rowCount);
}
/**
* per_page 미지정 시 기본값이 20 인지 확인
*
* 회귀: 백엔드 기본값이 15 였으나 프론트 셀렉트는 20 을 표시해
* '표시 20 / 실제 조회 15' 불일치 발생. 양쪽을 20 으로 통일한다.
*/
public function test_admin_list_default_per_page_is_20(): void
{
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages');
$response->assertStatus(200);
$this->assertSame(20, $response->json('data.meta.per_page'));
}
/**
* 본문(content)에만 있는 단어는 제목/전체 검색 어느 쪽에서도 검색되지 않는지 확인 (E4)
*
* 검색 대상은 제목·슬러그이며 본문은 포함하지 않는다 (UI '제목 또는 슬러그로 검색',
* 검색 필드 옵션 전체/제목/슬러그와 일치). '제목'뿐 아니라 '전체' 검색도 본문을 제외한다.
*
* 검증 범위: '본문 단어가 검색에 누출되지 않음'(항상 0건이라 트랜잭션 FULLTEXT 가시성과 무관하게 안정적).
*/
public function test_admin_search_excludes_content(): void
{
Page::factory()->create([
'slug' => 'test-content-scope',
'title' => ['ko' => '제목쪽고유단어', 'en' => 'Heading'],
'content' => ['ko' => '본문쪽고유단어', 'en' => 'Body'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
// 본문에만 있는 단어는 '제목' 검색에서 0건
$this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=본문쪽고유단어&search_field=title')
->assertStatus(200)
->assertJsonPath('data.meta.total', 0);
// 본문에만 있는 단어는 '전체' 검색에서도 0건 (본문은 검색 대상 아님)
$this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=본문쪽고유단어&search_field=all')
->assertStatus(200)
->assertJsonPath('data.meta.total', 0);
}
/**
* 검색조건 '전체'(all)는 제목 외에 슬러그도 검색해 title 보다 넓은 범위인지 확인 (E4 대조)
*
* '전체' 검색 = 제목 + 슬러그. title 검색이 제목만 보더라도, all 검색은 슬러그까지 포함해야 한다.
*/
public function test_admin_search_all_wider_than_title(): void
{
Page::factory()->create([
'slug' => 'test-allscope-zzslugword',
'title' => ['ko' => '무관한제목', 'en' => 'x'],
'content' => ['ko' => '본문', 'en' => 'y'],
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
// all 검색: 슬러그 토큰으로 잡혀야 함 (제목엔 없음)
$all = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zzslugword&search_field=all');
$all->assertStatus(200);
$this->assertGreaterThanOrEqual(1, $all->json('data.meta.total'));
// title 검색: 같은 슬러그 토큰은 제목에 없으므로 0건 (범위가 좁음을 대조)
$title = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?search=zzslugword&search_field=title');
$title->assertStatus(200);
$this->assertSame(0, $title->json('data.meta.total'));
}
/**
* 검색어가 최대 길이를 초과하면 500 이 아닌 422 검증 오류를 반환하는지 확인 (E5)
*
* 회귀: 공백 없는 긴 한글(140자+)이 FULLTEXT phrase 토큰 한도를 초과해
* 'Too many words in a FTS phrase'(191) → 500 을 유발했다.
* search max 를 100 으로 제한해 긴 입력을 422 로 사전 차단한다.
*/
public function test_admin_search_too_long_returns_422(): void
{
// 레이아웃이 실제로 쓰는 filters[0][value] 경로로 긴 검색어 전송 → 500 이 아닌 422
$this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?filters[0][field]=all&filters[0][value]='.str_repeat('가', 200))
->assertStatus(422);
}
// ─── 상세 조회 (show) ──────────────────────────────
@@ -180,6 +314,77 @@ class PageControllerTest extends FeatureTestCase
$response->assertStatus(404);
}
/**
* 관리자 상세 조회 시 첨부 URL이 공개 hash 라우트를 가리키는지 확인
*
* 썸네일 <img>·다운로드는 토큰을 실을 수 없어 인증 라우트에 물리면 401 로 깨진다.
* 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화하고, 미발행 콘텐츠
* 다운로드 차단은 공개 라우트 내부의 권한 게이트가 담당한다.
*/
public function test_admin_show_returns_public_attachment_urls(): void
{
$page = Page::factory()->create([
'slug' => 'test-admin-attach-url',
'published' => false,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$attachment = PageAttachment::factory()->image()->create([
'page_id' => $page->id,
'hash' => 'abc123def456',
'created_by' => $this->adminUser->id,
]);
$response = $this->actingAs($this->adminUser)
->getJson("/api/modules/sirsoft-page/admin/pages/{$page->id}");
$response->assertStatus(200);
$att = $response->json('data.attachments.0');
$this->assertSame(
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview",
$att['preview_url']
);
$this->assertSame(
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}",
$att['download_url']
);
}
/**
* 공개 상세 조회 시 첨부 URL이 공개 라우트를 유지하는지 확인
*
* 공개 응답에 admin URL(발행 가드 없음)이 섞이면 미발행 첨부가
* 노출되는 보안 회귀가 된다. 공개 응답은 공개 라우트만 반환해야 한다.
*/
public function test_public_show_returns_public_attachment_urls(): void
{
$page = Page::factory()->create([
'slug' => 'test-public-attach-url',
'published' => true,
'published_at' => now(),
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$attachment = PageAttachment::factory()->image()->create([
'page_id' => $page->id,
'hash' => 'pub123def456',
'created_by' => $this->adminUser->id,
]);
$response = $this->getJson("/api/modules/sirsoft-page/pages/{$page->slug}");
$response->assertStatus(200);
$att = $response->json('data.attachments.0');
$this->assertSame(
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview",
$att['preview_url']
);
$this->assertSame(
"/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}",
$att['download_url']
);
}
// ─── 생성 (store) ──────────────────────────────────
/**
@@ -383,7 +588,7 @@ class PageControllerTest extends FeatureTestCase
$response->assertStatus(200);
$this->assertDatabaseHas('pages', ['id' => $page->id, 'slug' => 'test-slug-new']);
$this->assertDatabaseMissing('pages', ['slug' => 'test-slug-old', 'deleted_at' => null]);
$this->assertDatabaseMissing('pages', ['slug' => 'test-slug-old']);
}
/**
@@ -593,7 +798,7 @@ class PageControllerTest extends FeatureTestCase
// ─── 삭제 (destroy) ────────────────────────────────
/**
* 페이지를 삭제(소프트 삭제)할 수 있는지 확인
* 페이지를 삭제(물리 삭제)할 수 있는지 확인
*/
public function test_admin_can_delete_page(): void
{
@@ -609,8 +814,8 @@ class PageControllerTest extends FeatureTestCase
$response->assertStatus(200)
->assertJsonPath('success', true);
// 소프트 삭제 확인
$this->assertSoftDeleted('pages', ['id' => $page->id]);
// 물리 삭제 확인 (soft delete 미사용 — 행이 실제로 제거됨)
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
}
/**
@@ -624,6 +829,117 @@ class PageControllerTest extends FeatureTestCase
$response->assertStatus(404);
}
/**
* 페이지 삭제 후 동일 슬러그로 재생성할 수 있는지 확인 (회귀)
*
* soft delete 시절에는 삭제된 페이지의 slug 가 잔존하여 동일 slug 재생성 시
* Rule::unique 가 422 를 반환했다. hard delete 전환으로 재생성이 성공해야 한다.
*/
public function test_can_recreate_page_with_same_slug_after_delete(): void
{
$slug = 'test-reuse-slug';
// 1) 최초 생성
$first = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '원본 페이지', 'en' => 'Original'],
'content' => ['ko' => '본문', 'en' => 'Content'],
'content_mode' => 'html',
]);
$first->assertStatus(201);
$firstId = $first->json('data.id');
// 2) 삭제
$this->actingAs($this->adminUser)
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$firstId}")
->assertStatus(200);
$this->assertDatabaseMissing('pages', ['id' => $firstId]);
// 3) 동일 slug 로 재생성 — 성공해야 함
$second = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '재생성 페이지', 'en' => 'Recreated'],
'content' => ['ko' => '본문', 'en' => 'Content'],
'content_mode' => 'html',
]);
$second->assertStatus(201)
->assertJsonPath('data.slug', $slug);
}
/**
* 페이지 삭제 후 슬러그 중복 체크와 생성 검증 결과가 일치하는지 확인 (회귀)
*
* 삭제 후 check-slug 는 "사용 가능", 생성도 성공해야 한다 (두 경로 정합).
*/
public function test_check_slug_and_create_are_consistent_after_delete(): void
{
$slug = 'test-consistent-slug';
$first = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '원본', 'en' => 'Original'],
'content_mode' => 'html',
]);
$first->assertStatus(201);
$firstId = $first->json('data.id');
$this->actingAs($this->adminUser)
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$firstId}")
->assertStatus(200);
// check-slug 는 사용 가능(exists=false)으로 응답
$check = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages/check-slug', [
'slug' => $slug,
]);
$check->assertStatus(200)
->assertJsonPath('data.exists', false);
// 생성도 성공 (check-slug 와 동일 결론)
$create = $this->actingAs($this->adminUser)
->postJson('/api/modules/sirsoft-page/admin/pages', [
'slug' => $slug,
'title' => ['ko' => '재생성', 'en' => 'Recreated'],
'content_mode' => 'html',
]);
$create->assertStatus(201);
}
/**
* 첨부가 있는 페이지를 물리 삭제해도 삭제 흐름(첨부 정리 + 활동로그 기록)이 정상 완료되는지 확인
*
* 활동로그 리스너는 삭제되는 페이지를 loggable 로 참조한다. hard delete 후 loggable 행이
* 사라지더라도 삭제 응답이 200 이고 페이지/첨부 행이 모두 물리 삭제되어야 한다.
*/
public function test_deleting_page_with_attachment_completes_without_error(): void
{
$page = Page::factory()->create([
'slug' => 'test-delete-with-attach',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$attachment = PageAttachment::factory()->create([
'page_id' => $page->id,
'created_by' => $this->adminUser->id,
]);
$response = $this->actingAs($this->adminUser)
->deleteJson("/api/modules/sirsoft-page/admin/pages/{$page->id}");
$response->assertStatus(200)
->assertJsonPath('success', true);
// 페이지 + 첨부 모두 물리 삭제
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
}
// ─── 발행 토글 (publish) ───────────────────────────
/**
@@ -699,6 +1015,11 @@ class PageControllerTest extends FeatureTestCase
->assertJsonPath('success', true)
->assertJsonPath('data.count', 3);
// 성공 메시지의 :count 플레이스홀더가 실제 건수로 치환되는지 확인 (회귀)
$message = $response->json('message');
$this->assertStringNotContainsString(':count', $message, '메시지에 :count 플레이스홀더가 치환되지 않고 남았습니다.');
$this->assertStringContainsString('3', $message, '메시지에 변경 건수(3)가 포함되어야 합니다.');
// DB 확인
foreach ($ids as $id) {
$this->assertDatabaseHas('pages', ['id' => $id, 'published' => true]);
@@ -831,7 +1152,7 @@ class PageControllerTest extends FeatureTestCase
]);
$response = $this->actingAs($this->adminUser)
->getJson('/api/modules/sirsoft-page/admin/pages?' . http_build_query([
->getJson('/api/modules/sirsoft-page/admin/pages?'.http_build_query([
'filters' => [
['field' => 'slug', 'value' => 'filters-target', 'operator' => 'like'],
],
@@ -977,6 +1298,102 @@ class PageControllerTest extends FeatureTestCase
$this->assertEquals(3, $page->current_version); // 복원 후 버전 증가
}
/**
* 버전 복원 시 페이지 상세 SEO 캐시가 무효화되는지 확인
*
* 복원은 title/content/seo_meta 를 이전 버전으로 되돌리므로 수정과 동일하게
* 검색봇용 SEO 캐시를 갱신해야 한다. 복원 전 상세 URL 캐시를 적재한 뒤 복원하면
* after_restore 훅 → SeoPageCacheListener 가 해당 캐시를 무효화해야 한다.
* (회귀 배경: after_restore 미구독으로 복원 후에도 봇에 복원 전 버전이 잔존)
*/
public function test_restore_version_invalidates_page_detail_seo_cache(): void
{
$page = Page::factory()->create([
'slug' => 'restore-seo-cache',
'title' => ['ko' => '현재 제목', 'en' => 'Current Title'],
'current_version' => 2,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$version1 = PageVersion::create([
'page_id' => $page->id,
'version' => 1,
'title' => ['ko' => '원래 제목', 'en' => 'Original Title'],
'content' => ['ko' => '원래 내용', 'en' => 'Original Content'],
'content_mode' => 'html',
'created_by' => $this->adminUser->id,
]);
PageVersion::create([
'page_id' => $page->id,
'version' => 2,
'title' => ['ko' => '현재 제목', 'en' => 'Current Title'],
'content' => ['ko' => '현재 내용', 'en' => 'Current Content'],
'content_mode' => 'html',
'created_by' => $this->adminUser->id,
]);
// 복원 전 페이지 상세 URL 캐시 적재 (봇 응답 캐시 시뮬레이션)
$cache = app(SeoCacheManagerInterface::class);
$cache->put("/page/{$page->slug}", 'ko', '<html>현재 내용</html>');
$this->assertContains("/page/{$page->slug}", $cache->getCachedUrls());
$response = $this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-page/admin/pages/{$page->id}/versions/{$version1->id}/restore");
$response->assertStatus(200);
// 복원 후 상세 캐시가 무효화되어 봇이 새로 렌더한다
$this->assertNotContains("/page/{$page->slug}", $cache->getCachedUrls());
}
/**
* 버전 복원 후 새 버전이 생성되고 기존 버전 이력이 모두 보존되는지 확인 (검수항목 3·4)
*
* 복원은 다음 버전 번호로 새 스냅샷을 만들며, 복원 대상을 포함한 기존 버전 이력을
* 삭제하지 않고 그대로 보존해야 한다.
*/
public function test_restore_version_creates_new_version_and_preserves_history(): void
{
$page = Page::factory()->create([
'slug' => 'restore-history-preserved',
'title' => ['ko' => '현재 제목', 'en' => 'Current Title'],
'current_version' => 2,
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
$version1 = PageVersion::create([
'page_id' => $page->id,
'version' => 1,
'title' => ['ko' => '원래 제목', 'en' => 'Original Title'],
'content' => ['ko' => '원래 내용', 'en' => 'Original Content'],
'content_mode' => 'html',
'created_by' => $this->adminUser->id,
]);
PageVersion::create([
'page_id' => $page->id,
'version' => 2,
'title' => ['ko' => '현재 제목', 'en' => 'Current Title'],
'content' => ['ko' => '현재 내용', 'en' => 'Current Content'],
'content_mode' => 'html',
'created_by' => $this->adminUser->id,
]);
$this->actingAs($this->adminUser)
->postJson("/api/modules/sirsoft-page/admin/pages/{$page->id}/versions/{$version1->id}/restore")
->assertStatus(200);
// 기존 버전 1·2 는 보존되고, 복원으로 버전 3 이 새로 생성되어 총 3건
$versions = PageVersion::where('page_id', $page->id)->orderBy('version')->get();
$this->assertCount(3, $versions);
$this->assertEquals([1, 2, 3], $versions->pluck('version')->all());
// 복원 대상(버전 1) 레코드가 삭제되지 않고 남아 있다
$this->assertNotNull(PageVersion::find($version1->id));
}
// ─── 인증/권한 차단 ────────────────────────────────
/**
@@ -1052,7 +1469,7 @@ class PageControllerTest extends FeatureTestCase
{
// 별도 역할을 생성하여 read 권한만 부여 (admin 역할과 분리)
$readOnlyRole = Role::create([
'identifier' => 'test_page_read_only_' . uniqid(),
'identifier' => 'test_page_read_only_'.uniqid(),
'name' => ['ko' => '읽기전용', 'en' => 'Read Only'],
'is_active' => true,
]);
@@ -1324,25 +1741,4 @@ class PageControllerTest extends FeatureTestCase
return $user;
}
/**
* Scout 엔진을 LIKE fallback 모드로 교체합니다.
*
* MySQL FULLTEXT는 트랜잭션 내 미커밋 데이터를 검색하지 못하므로,
* Scout 파이프라인 전체(Model::search → EngineManager → performSearch → 쿼리)를
* 검증하기 위해 LIKE fallback 엔진으로 교체합니다.
*
* @return void
*/
private function swapScoutEngineToLikeFallback(): void
{
$manager = $this->app->make(EngineManager::class);
$manager->extend('mysql-fulltext', fn () => new LikeFallbackEngine());
// EngineManager 캐시된 드라이버 인스턴스 초기화
$reflection = new \ReflectionClass($manager);
$property = $reflection->getProperty('drivers');
$property->setAccessible(true);
$property->setValue($manager, []);
}
}
@@ -68,7 +68,7 @@ class PageRepositorySearchTest extends TestCase
*/
protected function tearDown(): void
{
Page::query()->withTrashed()->forceDelete();
Page::query()->forceDelete();
if (isset($this->user)) {
User::where('id', $this->user->id)->delete();
@@ -9,7 +9,9 @@ use App\Contracts\Extension\StorageInterface;
use Mockery;
use Modules\Sirsoft\Page\Models\Page;
use Modules\Sirsoft\Page\Models\PageAttachment;
use Modules\Sirsoft\Page\Services\PageAttachmentService;
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 공개 페이지 API 테스트
@@ -37,8 +39,18 @@ class PublicPageControllerTest extends FeatureTestCase
$storageMock->shouldReceive('deleteDirectory')->andReturn(true);
$storageMock->shouldReceive('getDisk')->andReturn('local');
$storageMock->shouldReceive('url')->andReturn(null);
$storageMock->shouldReceive('response')->andReturn(null);
// 파일 서빙(다운로드/미리보기)은 200 스트리밍 응답을 반환 — 권한 게이트 검증용
$storageMock->shouldReceive('response')->andReturnUsing(
fn () => new StreamedResponse(fn () => print ('file content'), 200)
);
$this->app->instance(StorageInterface::class, $storageMock);
// PageAttachmentService 는 contextual binding 으로 모듈 도메인 Storage 를 받으므로
// (AbstractExtensionServiceProvider::$storageServices), 전역 instance 만으로는
// 오버라이드되지 않는다. 해당 서비스에도 mock 을 명시적으로 주입한다.
$this->app->when(PageAttachmentService::class)
->needs(StorageInterface::class)
->give(fn () => $storageMock);
}
/**
@@ -107,6 +119,152 @@ class PublicPageControllerTest extends FeatureTestCase
$response->assertStatus(404);
}
// ─── 미발행 페이지 관리자 미리보기 ────────
/**
* 페이지 조회 권한(pages.read) 관리자는 미발행 페이지를 미리볼 수 있는지 확인
*/
public function test_admin_with_read_permission_can_preview_unpublished_page(): void
{
$admin = $this->createAdminUser(['sirsoft-page.pages.read']);
Page::factory()->create([
'slug' => 'test-draft-preview',
'published' => false,
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
$response = $this->actingAs($admin)
->getJson('/api/modules/sirsoft-page/pages/test-draft-preview');
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.slug', 'test-draft-preview')
->assertJsonPath('data.is_preview', true);
}
/**
* 미발행 미리보기 응답의 첨부 URL이 공개 hash 라우트인지 확인
*
* 썸네일 <img>·다운로드는 브라우저 직접 GET 이라 토큰을 실을 수 없어 인증 라우트로
* 두면 401 로 깨진다. 게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화하고,
* 미발행 콘텐츠 보안은 다운로드 권한 게이트(공개 라우트 내부)로 담당한다.
*/
public function test_admin_preview_unpublished_page_attachments_use_public_url(): void
{
$admin = $this->createAdminUser(['sirsoft-page.pages.read']);
$page = Page::factory()->create([
'slug' => 'test-draft-preview-attach',
'published' => false,
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
// 이미지 첨부 — preview_url 은 이미지에만 생성되므로 image mime 사용
PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'draft.png',
'stored_filename' => 'stored-draft.png',
'disk' => 'local',
'path' => 'test/draft.png',
'mime_type' => 'image/png',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->actingAs($admin)
->getJson('/api/modules/sirsoft-page/pages/test-draft-preview-attach');
$response->assertStatus(200);
$downloadUrl = $response->json('data.attachments.0.download_url');
$previewUrl = $response->json('data.attachments.0.preview_url');
$this->assertStringContainsString('/pages/attachment/', $downloadUrl);
$this->assertStringContainsString('/pages/attachment/', $previewUrl);
$this->assertStringNotContainsString('/admin/attachments/', $downloadUrl);
$this->assertStringNotContainsString('/admin/attachments/', $previewUrl);
}
/**
* 페이지 권한이 없는 일반 회원은 미발행 페이지를 미리볼 수 없음(404)
*/
public function test_non_admin_user_cannot_preview_unpublished_page(): void
{
$author = $this->createAdminUser([]);
$member = $this->createUser();
Page::factory()->create([
'slug' => 'test-draft-member',
'published' => false,
'created_by' => $author->id,
'updated_by' => $author->id,
]);
$response = $this->actingAs($member)
->getJson('/api/modules/sirsoft-page/pages/test-draft-member');
$response->assertStatus(404);
}
/**
* pages.read 권한이 없는 관리자(다른 모듈 admin)는 미발행 페이지를 미리볼 수 없음(404)
*/
public function test_admin_without_pages_read_cannot_preview_unpublished_page(): void
{
// sirsoft-page.pages.read 가 아닌 다른 admin 권한만 보유
$otherAdmin = $this->createAdminUser(['sirsoft-other.things.read']);
Page::factory()->create([
'slug' => 'test-draft-other-admin',
'published' => false,
'created_by' => $otherAdmin->id,
'updated_by' => $otherAdmin->id,
]);
$response = $this->actingAs($otherAdmin)
->getJson('/api/modules/sirsoft-page/pages/test-draft-other-admin');
$response->assertStatus(404);
}
/**
* 발행된 페이지의 첨부 URL은 관리자 요청이어도 공개용 라우트를 유지하는지 확인
*/
public function test_published_page_attachments_use_public_url_even_for_admin(): void
{
$admin = $this->createAdminUser(['sirsoft-page.pages.read']);
$page = Page::factory()->published()->create([
'slug' => 'test-published-admin-attach',
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'public.pdf',
'stored_filename' => 'stored-public.pdf',
'disk' => 'local',
'path' => 'test/public.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->actingAs($admin)
->getJson('/api/modules/sirsoft-page/pages/test-published-admin-attach');
$response->assertStatus(200)
->assertJsonPath('data.is_preview', false);
$downloadUrl = $response->json('data.attachments.0.download_url');
$this->assertStringContainsString('/pages/attachment/', $downloadUrl);
}
/**
* 발행된 페이지 조회 시 첨부파일 목록이 포함되는지 확인
*/
@@ -133,7 +291,7 @@ class PublicPageControllerTest extends FeatureTestCase
'created_by' => $admin->id,
]);
$response = $this->getJson("/api/modules/sirsoft-page/pages/test-page-with-attach");
$response = $this->getJson('/api/modules/sirsoft-page/pages/test-page-with-attach');
$response->assertStatus(200)
->assertJsonStructure([
@@ -199,4 +357,204 @@ class PublicPageControllerTest extends FeatureTestCase
$response->assertStatus(404);
}
// ─── 썸네일(preview) 공개 서빙 매트릭스 (별건: 관리자 상세 썸네일 401 회귀) ───
/**
* 미발행 페이지의 이미지 첨부 미리보기(썸네일)는 비로그인이어도 200을 반환한다.
*
* 썸네일 <img> 는 토큰을 실을 수 없으므로 공개 hash 서빙으로 둔다.
* 보안은 hash 비추측성 + 파일 다운로드 권한 게이트가 담당한다(트레이드오프 수용).
*/
public function test_public_preview_unpublished_image_returns_200_for_guest(): void
{
$admin = $this->createAdminUser([]);
$page = Page::factory()->create([
'slug' => 'test-draft-preview-guest',
'published' => false,
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
$attachment = PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'draft.png',
'stored_filename' => 'stored-draft.png',
'disk' => 'local',
'path' => 'test/draft.png',
'mime_type' => 'image/png',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview");
$response->assertStatus(200);
}
/**
* 발행 페이지의 이미지 첨부 미리보기(썸네일)는 200을 반환한다.
*/
public function test_public_preview_published_image_returns_200(): void
{
$admin = $this->createAdminUser([]);
$page = Page::factory()->published()->create([
'slug' => 'test-pub-preview',
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
$attachment = PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'pub.png',
'stored_filename' => 'stored-pub.png',
'disk' => 'local',
'path' => 'test/pub.png',
'mime_type' => 'image/png',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}/preview");
$response->assertStatus(200);
}
// ─── 다운로드 권한 게이트 매트릭스 (별건: 관리자 상세 다운로드 401 회귀) ───
/**
* 미발행 페이지 첨부 다운로드는 비로그인에게 404로 차단된다.
*/
public function test_download_unpublished_attachment_blocked_for_guest(): void
{
$admin = $this->createAdminUser([]);
$page = Page::factory()->create([
'slug' => 'test-dl-guest',
'published' => false,
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
$attachment = PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'secret.pdf',
'stored_filename' => 'stored-secret.pdf',
'disk' => 'local',
'path' => 'test/secret.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}");
$response->assertStatus(404);
}
/**
* 미발행 페이지 첨부 다운로드는 권한 없는 일반 회원에게 404로 차단된다.
*/
public function test_download_unpublished_attachment_blocked_for_member(): void
{
$admin = $this->createAdminUser([]);
$member = $this->createUser();
$page = Page::factory()->create([
'slug' => 'test-dl-member',
'published' => false,
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
$attachment = PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'secret.pdf',
'stored_filename' => 'stored-secret.pdf',
'disk' => 'local',
'path' => 'test/secret.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->actingAs($member)
->getJson("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}");
$response->assertStatus(404);
}
/**
* 미발행 페이지 첨부 다운로드는 pages.read 권한 관리자에게 200(스트리밍)을 허용한다.
*/
public function test_download_unpublished_attachment_allowed_for_admin_with_read(): void
{
$admin = $this->createAdminUser(['sirsoft-page.pages.read']);
$page = Page::factory()->create([
'slug' => 'test-dl-admin',
'published' => false,
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
$attachment = PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'draft.pdf',
'stored_filename' => 'stored-draft.pdf',
'disk' => 'local',
'path' => 'test/draft.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->actingAs($admin)
->get("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}");
$response->assertStatus(200);
}
/**
* 발행 페이지 첨부 다운로드는 누구나 200(스트리밍)을 허용한다.
*/
public function test_download_published_attachment_allowed_for_guest(): void
{
$admin = $this->createAdminUser([]);
$page = Page::factory()->published()->create([
'slug' => 'test-dl-pub',
'created_by' => $admin->id,
'updated_by' => $admin->id,
]);
$attachment = PageAttachment::create([
'page_id' => $page->id,
'original_filename' => 'pub.pdf',
'stored_filename' => 'stored-pub.pdf',
'disk' => 'local',
'path' => 'test/pub.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'collection' => 'attachments',
'order' => 1,
'created_by' => $admin->id,
]);
$response = $this->get("/api/modules/sirsoft-page/pages/attachment/{$attachment->hash}");
$response->assertStatus(200);
}
}
@@ -0,0 +1,54 @@
/**
* 페이지 모듈 권한 fixture.
*
* 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼(issueToken / authenticatePage)를 재사용하되,
* 모듈 권한 토큰 fixture 를 자체적으로 정의한다. 권한 식별자는 임의 string 이므로 코어
* PlaywrightIssueToken 커맨드가 그대로 동작한다 (Permission::firstOrCreate 가 자동 생성).
*
* 권한 식별자 (module src 정의):
* sirsoft-page.pages.read / create / update / delete
*
* 활성화 절차 (페이지 모듈 후속 작업 세션):
* 1. 대상 화면 컴포넌트에 data-testid 보강 (각 spec docblock 의 "활성화 절차" 참조)
* 2. 해당 spec 의 test.describe.skip → test.describe 변경
*/
import { test as base } from '@playwright/test';
// 6단계 상위 = 코어 루트의 fixtures/auth.ts
// (modules/_bundled/sirsoft-page/tests/Playwright/fixtures → 코어 루트)
import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth';
type PageAuthFixtures = {
/** 페이지 조회 권한 토큰 (목록/상세 조회 검증용) */
pageReadToken: string;
/** 페이지 조회 + 생성 권한 토큰 (등록 폼 검증용) */
pageCreateToken: string;
/** 페이지 조회 + 수정 권한 토큰 (수정 폼/첨부/버전 검증용) */
pageManageToken: string;
/** 권한 없는 일반 사용자 토큰 (메뉴/화면 미노출 검증용) */
noPermissionToken: string;
};
export const test = base.extend<PageAuthFixtures>({
pageReadToken: async ({}, use) => {
await use(issueToken('sirsoft-page.pages.read'));
},
pageCreateToken: async ({}, use) => {
await use(issueToken('sirsoft-page.pages.read', 'sirsoft-page.pages.create'));
},
pageManageToken: async ({}, use) => {
await use(
issueToken(
'sirsoft-page.pages.read',
'sirsoft-page.pages.create',
'sirsoft-page.pages.update'
)
);
},
noPermissionToken: async ({}, use) => {
// 빈 권한 — 인증만 통과, 어떤 모듈 권한도 없음
await use(issueToken());
},
});
export { authenticatePage };
export { expect } from '@playwright/test';
@@ -0,0 +1,53 @@
/**
* 페이지 모듈 도메인 시드 fixture (stub).
*
* `playwright:seed-page` artisan 커맨드를 호출하여 도메인 데이터(페이지 + 첨부)를 발급받는다.
* 본 stub 은 시드 커맨드가 LogicException 을 throw 하도록 구성되어 있어(시그니처/보안 가드만 구현)
* 실제 사용 전에는 대상 spec 이 test.describe.skip 로 비활성화되어야 한다.
*
* 활성화 절차 (페이지 모듈 후속 작업 세션):
* 1. PlaywrightSeedPage 의 handle() 본문을 LogicException 에서 실제 시드 로직으로 교체
* (Page 모델 + 첨부 Factory 재사용, 업로드 순서 검증용 첨부 N건 포함)
* 2. 본 fixture 를 사용하는 spec 의 test.describe.skip 표기 제거
* 3. teardown 시 cleanup 커맨드(예: playwright:cleanup-page) 호출 추가 권장
*/
import { test as base } from '@playwright/test';
import { execSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 환경(package.json "type": "module")에서는 __dirname 미정의 → import.meta.url 로 재구성.
const __dirname = dirname(fileURLToPath(import.meta.url));
type SeedFixtures = {
/** 시드된 페이지 도메인 데이터 (고정 슬러그 미발행/발행 페이지 + 첨부 ID 배열) */
seededPage: {
unpublished_slug: string;
published_slug: string;
unpublished_id: number;
published_id: number;
attachment_ids: number[];
};
};
export const test = base.extend<SeedFixtures>({
seededPage: async ({}, use) => {
// 6단계 상위 = 코어 루트 (artisan 실행 cwd)
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../../');
const out = execSync('php artisan playwright:seed-page --attachments=2 --json', {
cwd: coreRoot,
encoding: 'utf-8',
env: {
...process.env,
G7_PLAYWRIGHT_BYPASS: '1',
},
});
// artisan 출력에 경고 등이 섞일 수 있으므로 마지막 JSON 라인만 파싱
const jsonLine = out.trim().split(/\r?\n/).filter((l) => l.trim().startsWith('{')).pop() ?? '{}';
const seed = JSON.parse(jsonLine);
await use(seed);
// TODO: teardown 시 cleanup 커맨드 호출
},
});
export { expect } from '@playwright/test';
@@ -0,0 +1,74 @@
/**
* 페이지 모듈 Playwright E2E 설정.
*
* 코어 `playwright.config.ts` 및 이커머스 모듈 설정과 동일한 base URL 해석 우선순위를 따른다 —
* 모듈도 활성 호스트가 가변(개발자/CI/PO 환경별로 다른 도메인)이므로 하드코딩 회피.
*
* Base URL 해석:
* 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드)
* 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합
* 3. 그 외 — 명시 에러
*
* 실행 예시:
* PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npm run test:e2e
* Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npm run test:e2e
*
* 모듈은 코어 fixture 의 `issueToken` / `authenticatePage` 헬퍼를 재사용 — 권한 식별자는
* `sirsoft-page.*` 등 임의 string. 시드는 모듈 자체 artisan 커맨드(playwright:seed-page) 호출.
*/
import { defineConfig, devices } from '@playwright/test';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로
// import.meta.url 로 재구성한다.
const __dirname = dirname(fileURLToPath(import.meta.url));
function readEnvFile(filePath: string, key: string): string | null {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, { encoding: 'utf-8' });
const pattern = new RegExp(`^${key}=(.*)$`, 'm');
const match = content.match(pattern);
if (!match) return null;
let value = match[1].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
return value || null;
}
function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) {
return process.env.PLAYWRIGHT_BASE_URL;
}
// 모듈 디렉토리 기준 5단계 상위 = 코어 루트 (.env 위치)
// (modules/_bundled/sirsoft-page/tests/Playwright → 코어 루트)
const coreRoot = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
const appUrl = readEnvFile(resolve(coreRoot, '.env'), 'APP_URL');
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
return appUrl;
}
throw new Error(
'페이지 모듈 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.'
);
}
export default defineConfig({
testDir: './specs',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['list'],
],
use: {
baseURL: resolveBaseUrl(),
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
@@ -0,0 +1,63 @@
/**
* 페이지 첨부 다중 업로드 순서 / 드래그 재정렬 → form.attachments 동기화
* (placeholder — data-testid 보강 후 활성화).
*
* @scenario upload_count=single, upload_count=multiple, form_mode=create, form_mode=edit
* @effects temp_attachments_linked_in_upload_order_1_to_n,
* edit_mode_drag_persists_via_reorder_api
*
* e2e:allow 다중 업로드 시 첨부가 올린 순서대로 저장되고(생성 모드 temp_key 연결),
* 수정 모드 드래그 재정렬이 reorder API 로 즉시 저장되어 form.attachments 에 반영되는
* 동작을 백엔드 Feature/Unit 테스트와 레이아웃 렌더 테스트가 커버한다. 본 placeholder
* spec(test.describe.skip)은 data-testid 보강 후 활성화된다.
* - 생성 모드 업로드 순서 1..N 재부여: PageAttachmentServiceTest (unit)
* - onUploadComplete/onReorder/onRemove → form.attachments 동기화:
* admin-page-layouts.test.ts > "첨부 순서 — form.attachments 동기화" (레이아웃 렌더)
* 브라우저 실제 드래그 인터랙션의 순서 영속만 본 spec 이 확인한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
* 1. FileUploader 첨부 아이템에 data-testid="page-attachment-item" (순서 assert 용)
* 2. 첨부 아이템 드래그 핸들에 data-testid="page-attachment-drag-handle"
* 3. seededPage fixture(playwright:seed-page) 실 시드 로직 구현 (첨부 N건 order 1..N)
* 4. test.describe.skip → test.describe 변경
*
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 upload_count × form_mode 와 1:1):
* - create × multiple: 업로드(조회) 순서대로 저장 후 목록 순서 유지
* - edit × multiple: 드래그로 순서 변경 후 재조회 시 변경 순서 영속
*/
import { mergeTests } from '@playwright/test';
import { test as authTest, expect, authenticatePage } from '../../fixtures/page-auth';
import { test as seedTest } from '../../fixtures/page-seed';
const test = mergeTests(authTest, seedTest);
const CREATE_URL = '/admin/pages/create';
const EDIT_URL = '/admin/pages/1/edit';
test.describe.skip('페이지 첨부 순서 동기화 (placeholder — data-testid 보강 후 활성화)', () => {
test('등록 폼에서 여러 첨부를 올리면 올린 순서대로 목록에 표시된다', async ({
page,
pageCreateToken,
}) => {
await authenticatePage(page, pageCreateToken);
await page.goto(CREATE_URL);
// (data-testid 보강 후) 파일 3건을 순차 업로드하고 목록 순서가 업로드 순서와 일치하는지 확인
await expect(page.getByTestId('page-attachment-item')).toHaveCount(3);
});
test('수정 폼에서 첨부를 드래그로 재정렬하면 변경된 순서가 영속된다', async ({
page,
pageManageToken,
seededPage,
}) => {
await authenticatePage(page, pageManageToken);
await page.goto(EDIT_URL);
const items = page.getByTestId('page-attachment-item');
await expect(items).toHaveCount(seededPage.attachmentIds.length);
// (data-testid 보강 후) 첫 아이템을 마지막으로 드래그 → reorder API 발화 → 재조회 시 순서 영속
// 드래그 인터랙션 + 재조회 후 순서 assert 는 활성화 시 구현
});
});
@@ -0,0 +1,58 @@
/**
* 페이지 폼 취소 버튼 이동 경로 — 수정 모드는 상세로, 등록 모드는 목록으로
* (placeholder — data-testid 보강 후 활성화).
*
* @scenario form_mode=create, form_mode=edit
* @effects cancel_in_edit_mode_navigates_to_detail,
* cancel_in_create_mode_navigates_to_list
*
* e2e:allow 취소 버튼이 navigateBack(history.back)이던 회귀 — 직전 위치(다른 관리 메뉴)로
* 튀거나 URL 직접 진입 시 멈추던 문제를, navigate + route.id 분기(수정=상세 /admin/pages/{id},
* 등록=목록 /admin/pages)로 교체했다.
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "[M6] 취소 버튼이 navigateBack 이 아니라
* navigate 로 목록/상세를 명시함")가 핸들러가 navigateBack 이 아니라 navigate 이고 path 표현식이
* route.id 분기를 가짐을 구조적으로 회귀 차단한다. 본 placeholder spec(test.describe.skip)은
* 취소 버튼에 data-testid 보강 후 활성화되어 실제 라우트 이동(직접 진입 → 취소 → 목록/상세)을 확인한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다:
* 1. 취소 버튼(footer_cancel_button)에 data-testid="page-form-cancel"
* 2. seededPage fixture(playwright:seed-page) 실 시드 로직 구현
* 3. test.describe.skip → test.describe 변경
*
* 매트릭스(시나리오 매니페스트 form_mode axis 와 1:1):
* - edit 모드 직접 진입 → 취소 → /admin/pages/{id} (상세)
* - create 모드 직접 진입 → 취소 → /admin/pages (목록)
*/
import { test, expect, authenticatePage } from '../../fixtures/page-auth';
const EDIT_URL = '/admin/pages/1/edit';
const CREATE_URL = '/admin/pages/create';
test.describe.skip('페이지 폼 취소 이동 경로 (placeholder — data-testid 보강 후 활성화)', () => {
test('수정 폼에 직접 진입 후 취소하면 해당 페이지 상세로 이동한다', async ({
page,
pageManageToken,
}) => {
await authenticatePage(page, pageManageToken);
// URL 직접 진입 (직전 history 없음 — navigateBack 이면 목록/상세로 못 감)
await page.goto(EDIT_URL);
await page.getByTestId('page-form-cancel').click();
// 수정 모드 → 상세로 이동
await expect(page).toHaveURL(new RegExp('/admin/pages/1$'));
});
test('등록 폼에 직접 진입 후 취소하면 목록으로 이동한다', async ({
page,
pageCreateToken,
}) => {
await authenticatePage(page, pageCreateToken);
await page.goto(CREATE_URL);
await page.getByTestId('page-form-cancel').click();
// 등록 모드 → 목록으로 이동
await expect(page).toHaveURL(new RegExp('/admin/pages$'));
});
});
@@ -0,0 +1,82 @@
/**
* 페이지 수정→등록 라우트 전환 시 폼/첨부/검증상태 잔존 차단 + 슬러그 미리보기 fallback
* (placeholder — data-testid 보강 후 활성화).
*
* @scenario form_mode=create, form_mode=edit
* @effects create_form_clears_stale_attachments_via_form_attachments_binding,
* create_form_clears_stale_title_slug_and_slug_check_state,
* slug_hint_url_preview_falls_back_to_example_on_empty_slug
*
* e2e:allow 수정→등록 SPA 라우트 전환 시 이전 페이지의 제목/슬러그/첨부/검증상태가 등록 폼에
* 잔존하던 회귀와, 빈 슬러그에서 미리보기가 "/page/{{slug}}" 처럼 미치환되던 회귀를
* 레이아웃 렌더 테스트가 구조적으로 차단한다. 본 placeholder spec(test.describe.skip)은
* data-testid 보강 후 활성화된다.
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "수정→등록 SPA 전환 시 폼/첨부 잔존 차단")가
* (1) 생성 모드 init_actions 가 _local.form 을 null→빈 기본값으로 초기화해 이전
* id/title/slug/attachments 참조를 끊고,
* (2) FileUploader initialFiles 가 _local.form.attachments 에 바인딩되며,
* (3) slug_hint 가 빈 슬러그('')에서 || 'example' fallback 으로 {{slug}} 미치환을 차단함을
* 구조적으로 회귀 차단한다. 브라우저 실 전환(라우트 A→B)의 리마운트 동작만 본 spec 이 확인한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
* 1. 페이지 제목 입력에 data-testid="page-title-input"
* 2. 슬러그 입력에 data-testid="page-slug-input"
* 3. 슬러그 URL 미리보기 텍스트에 data-testid="page-slug-hint"
* 4. 첨부 목록 컨테이너/아이템에 data-testid="page-attachment-item"
* 5. seededPage fixture(playwright:seed-page) 실 시드 로직 구현
* 6. test.describe.skip → test.describe 변경
*
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 form_mode axis 와 1:1):
* - edit→create 전환: 이전 제목/슬러그/첨부가 등록 폼에 비워진 상태로 진입
* - create 진입 직후: 슬러그 미입력 상태에서 미리보기가 "/page/example"
* - create 에서 슬러그 입력: 미리보기가 입력값으로 즉시 치환
*/
import { test, expect, authenticatePage } from '../../fixtures/page-auth';
const EDIT_URL = '/admin/pages/1/edit';
const CREATE_URL = '/admin/pages/create';
test.describe.skip('페이지 수정→등록 전환 잔존 차단 (placeholder — data-testid 보강 후 활성화)', () => {
test('수정 폼에서 등록 폼으로 전환하면 이전 제목/슬러그/첨부가 잔존하지 않는다', async ({
page,
pageManageToken,
}) => {
await authenticatePage(page, pageManageToken);
// 1) 수정 폼 진입 — 기존 페이지의 제목/슬러그/첨부가 채워진 상태
await page.goto(EDIT_URL);
await expect(page.getByTestId('page-title-input')).not.toHaveValue('');
// 2) 등록 폼으로 SPA 전환 (같은 layout_name 재사용 경로)
await page.getByTestId('page-nav-create').click();
await expect(page).toHaveURL(new RegExp('/admin/pages/create$'));
// 3) 이전 값이 잔존하지 않아야 함
await expect(page.getByTestId('page-title-input')).toHaveValue('');
await expect(page.getByTestId('page-slug-input')).toHaveValue('');
await expect(page.getByTestId('page-attachment-item')).toHaveCount(0);
});
test('등록 폼 진입 직후 슬러그 미입력이면 미리보기가 /page/example 로 표시된다', async ({
page,
pageCreateToken,
}) => {
await authenticatePage(page, pageCreateToken);
await page.goto(CREATE_URL);
// 빈 슬러그에서 {{slug}} 미치환이 아니라 example fallback
await expect(page.getByTestId('page-slug-hint')).toContainText('/page/example');
await expect(page.getByTestId('page-slug-hint')).not.toContainText('{{slug}}');
});
test('등록 폼에서 슬러그를 입력하면 미리보기가 입력값으로 즉시 치환된다', async ({
page,
pageCreateToken,
}) => {
await authenticatePage(page, pageCreateToken);
await page.goto(CREATE_URL);
await page.getByTestId('page-slug-input').fill('my-new-page');
await expect(page.getByTestId('page-slug-hint')).toContainText('/page/my-new-page');
});
});
@@ -0,0 +1,42 @@
/**
* 페이지 상세 버전 이력의 변경 필드명 다국어 라벨 렌더
* (placeholder — data-testid 보강 후 활성화).
*
* @scenario page_change=update
* @effects page_version_changed_fields_render_localized_labels
*
* e2e:allow 페이지 상세의 버전 이력 "변경 항목" 이 필드 키(title/slug/content 등) 원문 대신
* 다국어 라벨로 표시되는 동작을 레이아웃 렌더 테스트가 구조적으로 커버한다. 본
* placeholder spec(test.describe.skip)은 data-testid 보강 후 활성화된다.
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "변경내역 필드명 다국어 매핑")가
* changes_summary.changed_fields 를
* $t('sirsoft-page.admin.page.detail.versions.field_labels.<field>') 로 매핑함을
* 구조적으로 회귀 차단한다. 브라우저 실제 로케일 렌더만 본 spec 이 확인한다.
*
* 본 spec 은 다음 사전 작업 완료 후 활성화한다 (data-testid 보강):
* 1. 버전 이력 행의 변경 항목 셀에 data-testid="page-version-changed-fields"
* 2. seededPage fixture(playwright:seed-page) 실 시드 로직 구현 (버전 이력 있는 페이지)
* 3. test.describe.skip → test.describe 변경
*
* 매트릭스(시나리오 매니페스트 page-attachment-order-and-seo-cache.yaml 의 page_change=update 와 1:1):
* - update 후 상세 진입: 변경 항목이 필드 키 원문이 아닌 다국어 라벨로 표시
*/
import { test, expect, authenticatePage } from '../../fixtures/page-auth';
const DETAIL_URL = '/admin/pages/1';
test.describe.skip('페이지 버전 변경 필드 다국어 라벨 (placeholder — data-testid 보강 후 활성화)', () => {
test('버전 이력의 변경 항목이 필드 키 원문 대신 다국어 라벨로 표시된다', async ({
page,
pageReadToken,
}) => {
await authenticatePage(page, pageReadToken);
await page.goto(DETAIL_URL);
const changedFields = page.getByTestId('page-version-changed-fields').first();
// 필드 키 원문(title/slug/content)이 그대로 노출되지 않아야 함
await expect(changedFields).not.toHaveText(/\b(title|slug|content)\b/);
// 다국어 라벨(예: 제목/주소/내용)로 렌더 — 활성화 시 구체 라벨 assert
await expect(changedFields).not.toHaveText('');
});
});
@@ -0,0 +1,94 @@
/**
* 미발행 페이지 관리자 미리보기.
*
* @scenario viewer=guest, viewer=member, viewer=other_admin, viewer=page_reader, published=published, published=unpublished
* @effects page_reader_admin_previews_unpublished_page_with_200,
* preview_banner_renders_when_is_preview_true,
* preview_banner_hidden_for_published_or_guest,
* guest_gets_404_for_unpublished_page,
* member_gets_404_for_unpublished_page
*
* 정책: 페이지 조회 권한(sirsoft-page.pages.read) 보유 관리자만 사용자 화면(/page/{slug})에서
* 미발행 페이지를 미리볼 수 있고, 이때 "미발행 페이지 미리보기 중" 배너가 노출된다.
* 비로그인·일반 회원·페이지 권한 없는 관리자는 미발행 페이지에서 404(페이지를 찾을 수 없습니다) 를 본다.
* 발행 페이지는 누구에게나 배너 없이 노출된다.
*
* DOM 식별: 레이아웃 노드 id 는 HTML id 로 렌더되지 않으므로(콘텐츠 노드는 id 속성 미출력)
* 사용자에게 보이는 텍스트로 식별한다(getByText 는 콘텐츠 비동기 렌더를 auto-wait).
* - 미리보기 배너: "미발행 페이지 미리보기 중입니다"
* - not-found: "페이지를 찾을 수 없습니다"
* - 시드 페이지 제목: 미발행="미발행 미리보기 대상" / 발행="발행 대조군"
*
* 시드: playwright:seed-page 가 고정 슬러그를 발급한다.
* - 미발행: test-e2e-unpublished-preview (제목 "미발행 미리보기 대상")
* - 발행: test-e2e-published-preview (제목 "발행 대조군")
*/
import { mergeTests } from '@playwright/test';
import { test as authTest, expect, authenticatePage } from '../../fixtures/page-auth';
import { test as seedTest } from '../../fixtures/page-seed';
const test = mergeTests(authTest, seedTest);
const UNPUBLISHED_URL = '/page/test-e2e-unpublished-preview';
const PUBLISHED_URL = '/page/test-e2e-published-preview';
// 앱 locale(ko/en)에 무관하게 매칭 — 실행 환경 기본 locale 이 EN 일 수 있다.
// 제목은 seed 가 넣은 고정 문자열이라 locale 무관.
const PREVIEW_BANNER_TEXT = /미발행 페이지 미리보기 중입니다|Previewing an unpublished page/;
const NOT_FOUND_TEXT = /페이지를 찾을 수 없습니다|Page Not Found/;
const UNPUBLISHED_TITLE = '미발행 미리보기 대상';
const PUBLISHED_TITLE = '발행 대조군';
test.describe('미발행 페이지 관리자 미리보기', () => {
test('페이지 조회 권한 관리자는 미발행 페이지를 미리보고 배너가 노출된다', async ({
page,
pageReadToken,
seededPage,
}) => {
expect(seededPage.unpublished_slug).toBe('test-e2e-unpublished-preview');
await authenticatePage(page, pageReadToken);
await page.goto(UNPUBLISHED_URL);
// 미발행이지만 권한자라 본문 노출 + 미리보기 배너 노출
await expect(page.getByRole('heading', { name: UNPUBLISHED_TITLE })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(PREVIEW_BANNER_TEXT)).toBeVisible();
await expect(page.getByText(NOT_FOUND_TEXT)).toHaveCount(0);
});
test('비로그인 사용자는 미발행 페이지에서 not-found 를 본다', async ({ page, seededPage }) => {
expect(seededPage.unpublished_slug).toBe('test-e2e-unpublished-preview');
// 인증 없음(게스트)
await page.goto(UNPUBLISHED_URL);
await expect(page.getByRole('heading', { name: NOT_FOUND_TEXT })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(PREVIEW_BANNER_TEXT)).toHaveCount(0);
});
/*
* "권한 없는 로그인 사용자 / pages.read 없는 다른 모듈 관리자 → 404" 케이스는 E2E 로
* 재현 불가하여 PHPUnit 이 담당한다. 근거: PlaywrightIssueToken.makeAdminUser 는 발급 유저를
* 무조건 코어 `admin` 롤에 부여하는데, 활성 DB 의 admin 롤은 시드로 sirsoft-page.pages.read 를
* 포함한 전체 권한(131개)을 이미 보유한다. 따라서 issueToken() 으로는 "페이지 권한이 없는 유저"
* 를 만들 수 없다(항상 pages.read 를 상속). 해당 게이트 분기는 아래 PHPUnit 이 격리 검증한다:
* - PublicPageControllerTest::test_non_admin_user_cannot_preview_unpublished_page
* - PublicPageControllerTest::test_admin_without_pages_read_cannot_preview_unpublished_page
* (createAdminUser 는 admin 롤에 명시 권한만 syncWithoutDetaching 하여 격리 가능)
*/
test('발행된 페이지는 배너 없이 노출된다 (관리자 포함)', async ({
page,
pageReadToken,
seededPage,
}) => {
expect(seededPage.published_slug).toBe('test-e2e-published-preview');
await authenticatePage(page, pageReadToken);
await page.goto(PUBLISHED_URL);
await expect(page.getByRole('heading', { name: PUBLISHED_TITLE })).toBeVisible({ timeout: 30_000 });
// 발행 페이지는 is_preview=false → 배너 미노출
await expect(page.getByText(PREVIEW_BANNER_TEXT)).toHaveCount(0);
});
});
@@ -40,14 +40,18 @@ class SeoPageCacheListenerTest extends ModuleTestCase
$this->assertArrayHasKey('sirsoft-page.page.after_create', $hooks);
$this->assertArrayHasKey('sirsoft-page.page.after_update', $hooks);
$this->assertArrayHasKey('sirsoft-page.page.after_delete', $hooks);
// 버전 복원도 SEO 캐시 무효화가 필요하다 (복원 후 봇에 복원 전 버전 잔존 회귀 방지)
$this->assertArrayHasKey('sirsoft-page.page.after_restore', $hooks);
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_create']['method']);
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_update']['method']);
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_delete']['method']);
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_restore']['method']);
$this->assertEquals(20, $hooks['sirsoft-page.page.after_create']['priority']);
$this->assertEquals(20, $hooks['sirsoft-page.page.after_update']['priority']);
$this->assertEquals(20, $hooks['sirsoft-page.page.after_delete']['priority']);
$this->assertEquals(20, $hooks['sirsoft-page.page.after_restore']['priority']);
}
// ─── onPageChange: 캐시 무효화 상태 검증 ──────────────
@@ -135,6 +139,29 @@ class SeoPageCacheListenerTest extends ModuleTestCase
$this->listener->onPageChange($page);
}
/**
* onPageChange 호출 시 페이지 상세 URL 캐시가 URL 패턴으로 무효화되는지 확인합니다.
*
* 페이지 공개 URL은 단수형 /page/{slug} 이다 (PageSitemapContributor, SearchPagesListener 동일).
* 레이아웃 태그 없이 put()으로 캐시된 항목은 invalidateByLayout('page/show')로는 지워지지 않으므로,
* invalidateByUrl 의 와일드카드 패턴이 실제 URL(/page/{slug})과 일치해야만 무효화된다.
*
* 회귀 배경: 기존 패턴은 복수형 "*​/pages/{slug}" 라서 실제 단수형 URL과 매칭되지 않아
* 레이아웃 태그가 없는 페이지 상세 캐시가 영구히 남았다.
*/
public function test_on_page_change_invalidates_page_detail_url_by_pattern(): void
{
$page = (object) ['id' => 6, 'slug' => 'pattern-page'];
// 레이아웃 태그 없이 페이지 상세 URL 캐시 (URL 패턴 매칭으로만 무효화 가능)
$this->cache->put('/page/pattern-page', 'ko', '<html>detail</html>');
$this->assertContains('/page/pattern-page', $this->cache->getCachedUrls());
$this->listener->onPageChange($page);
$this->assertNotContains('/page/pattern-page', $this->cache->getCachedUrls());
}
// ─── handle (인터페이스 준수) ───────────────────────────
/**
@@ -157,12 +157,40 @@ class PageAttachmentServiceTest extends ModuleTestCase
$this->assertNull($tempAttachment->temp_key);
}
/**
* 연결 시 업로드(조회) 순서대로 order가 1..N 재부여되는지 확인
*
* 이커머스 ProductImageService::linkTempImages 와 동일하게, getByTempKey 가
* order 순으로 반환한 순서대로 order 를 1,2,3 으로 재배치한다. (생성 모드의
* 드래그 재정렬은 미지원 — 업로드 순서가 SSoT, 재정렬은 수정 모드 reorder API)
*/
public function test_link_reassigns_order_by_upload_sequence(): void
{
$page = Page::factory()->create([
'slug' => 'test-svc-link-order',
'created_by' => $this->adminUser->id,
'updated_by' => $this->adminUser->id,
]);
// 임시 첨부 3개 업로드 (order 는 업로드 시점에 1,2,3 순차 부여됨)
$a = $this->service->upload(UploadedFile::fake()->create('a.pdf', 100, 'application/pdf'), null, 'attachments', 'order-key');
$b = $this->service->upload(UploadedFile::fake()->create('b.pdf', 100, 'application/pdf'), null, 'attachments', 'order-key');
$c = $this->service->upload(UploadedFile::fake()->create('c.pdf', 100, 'application/pdf'), null, 'attachments', 'order-key');
$this->service->linkTempAttachmentsWithMove('order-key', $page->id);
// 연결 후 업로드(조회) 순서대로 order 1,2,3 재부여
$this->assertEquals(1, $a->fresh()->order);
$this->assertEquals(2, $b->fresh()->order);
$this->assertEquals(3, $c->fresh()->order);
}
// ─── deleteAttachment ──────────────────────────────
/**
* 첨부파일을 삭제할 수 있는지 확인
*/
public function test_delete_attachment_soft_deletes(): void
public function test_delete_attachment_hard_deletes(): void
{
$page = Page::factory()->create([
'slug' => 'test-svc-del-attach',
@@ -176,7 +204,7 @@ class PageAttachmentServiceTest extends ModuleTestCase
$result = $this->service->deleteAttachment($attachment);
$this->assertTrue($result);
$this->assertSoftDeleted('page_attachments', ['id' => $attachment->id]);
$this->assertDatabaseMissing('page_attachments', ['id' => $attachment->id]);
}
// ─── reorder ───────────────────────────────────────
@@ -4,6 +4,7 @@ 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;
@@ -144,16 +145,16 @@ class PageServiceTest extends ModuleTestCase
// ─── deletePage ────────────────────────────────────
/**
* 페이지를 소프트 삭제할 수 있는지 확인
* 페이지를 물리 삭제할 수 있는지 확인
*/
public function test_delete_page_soft_deletes(): void
public function test_delete_page_hard_deletes(): void
{
$page = $this->createTestPage('test-svc-delete');
$result = $this->service->deletePage($page);
$this->assertTrue($result);
$this->assertSoftDeleted('pages', ['id' => $page->id]);
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
}
// ─── changePublishStatus ───────────────────────────
@@ -206,6 +207,105 @@ class PageServiceTest extends ModuleTestCase
);
$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 ────────────────────────────────
@@ -301,6 +401,35 @@ class PageServiceTest extends ModuleTestCase
$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) ────
/**
@@ -0,0 +1,110 @@
# audit:allow test-scenario-coverage reason: |
# 본 매니페스트는 서로 독립적인 5개 페이지 검수 결함(첨부 URL 컨텍스트 6-1 / 첨부 순서 6-2 /
# 수정→등록 폼 잔존 7 / SEO 캐시 무효화 12-2 / 버전 변경 필드 다국어 라벨 13)을 한 회귀
# 매트릭스로 묶어 기록한다. 축(url_context/form_mode/upload_count/page_change/cached_entry)은
# 결함별로 서로 무관해 full cross product(48케이스)에 "public × delete × multiple" 같은
# 무의미 조합이 다수 포함되므로 docblock @scenario 전수 마킹이 부적합하다.
# 실제 회귀 가드는 test_files 의 통과 테스트가 결함별로 담당한다:
# 6-1 → PageControllerTest(Feature) / 6-2 → PageAttachmentServiceTest(Unit) +
# admin-page-layouts.test.ts(레이아웃 렌더) / 7(폼 잔존) → admin-page-layouts.test.ts /
# 12-2 → SeoPageCacheListenerTest(Unit) / 13 → admin-page-layouts.test.ts /
# 7(검증 안내 toast) → admin-page-layouts.test.ts(onUploadError→toast 핸들러 존재) +
# FileUploaderLimit.test.ts(개수 초과 발화). upload_validation 축은 용량/확장자/개수/정상
# 4값이나 용량·확장자는 컴포넌트 기존 검증이라 레이아웃 핸들러 존재로, 개수는 훅 단위로 커버.
# E2E: 페이지 모듈에 Playwright 인프라를 이커머스와 동일 패턴으로 신설(config + page-auth/
# page-seed fixture + playwright:seed-page stub)하고 spec 3건을 form_mode/upload_count/
# page_change axis 와 1:1 매핑해 등록했으며, 대상 화면 data-testid 보강 전까지 skip placeholder
# 로 운용한다(이커머스 product-seo-input-debounce 와 동일 운용).
feature: 페이지 첨부 컨텍스트 URL / 다중 업로드 최종 순서 저장 / 페이지 변경 시 SEO 캐시 무효화 / 수정→등록 폼 잔존 차단 / 버전 변경 필드 다국어 라벨
description: |
페이지 검수 오류 회귀를 한 매트릭스로 묶어 보장한다.
6-1 (첨부 URL — 공개 hash 단일화):
- 관리자/공개 화면 모두 공개 hash 라우트(/pages/attachment/{hash}[/preview])를 사용한다.
- 썸네일 <img>·다운로드 <a> 는 토큰을 실을 수 없어 인증 라우트에 물리면 401 로 깨진다.
게시판·이커머스 표준과 동일하게 공개 hash 라우트로 단일화하고, 미발행 첨부 파일 다운로드
차단은 공개 라우트 내부 권한 게이트가 담당한다. (별건 page-attachment-serving.yaml 이 SSoT.)
6-2 (다중 업로드 / 첨부 순서 — 이커머스 상품 이미지와 동일 패턴):
- 생성 모드: temp_key 로 업로드된 첨부를 저장 시 업로드(조회) 순서대로 order 1..N 재부여
(linkTempAttachmentsWithMove). 드래그 재정렬은 생성 모드에서 미지원 — 업로드 순서가 SSoT.
- 수정 모드: 드래그 시 즉시 reorder API(PATCH /attachments/reorder)로 순서를 저장하고,
onReorder 가 form.attachments 를 최종 순서로 동기화.
- 저장 body 는 _local.form + temp_key (이커머스 image_temp_key 와 동일). 별도 attachment_order
페이로드/코어 emitEvent 결과 의존 없음.
- 등록 폼 잔존 버그: 수정→등록 라우트 전환은 같은 layout_name 이라 _local·컴포넌트가 보존된다.
FileUploader 의 initialFiles 를 pageData?.data?.attachments(등록 모드 absent → 참조 불변) 대신
_local.form.attachments 에 바인딩하고, init_actions 에서 생성 모드 form 을 null→빈 기본값으로
초기화하여 참조를 바꿈으로써 동기화 useEffect 가 발화해 이전 첨부/제목/슬러그/검증상태가 비워진다.
slug_hint URL 미리보기는 빈 슬러그('')에서 || 'example' fallback 으로 {{slug}} 미치환을 차단.
(공용 FileUploader 컴포넌트·코어 템플릿 엔진 무수정 — 레이아웃 바인딩/init_actions 만 변경)
12-2 (SEO 캐시 무효화):
- 페이지 생성/수정/삭제 시 페이지 상세 SEO 캐시가 즉시 갱신돼야 한다.
- 공개 URL 은 단수형 /page/{slug} (PageSitemapContributor, SearchPagesListener 동일).
- 이전 패턴은 복수형 */pages/{slug} 라 실제 URL 과 매칭되지 않아, 레이아웃 태그 없이
put() 으로 캐시된 상세 캐시가 영구히 남았다 → */page/{slug} 로 정정.
13 (버전 변경 필드 다국어 라벨):
- 페이지 상세 버전 이력의 "변경 항목" 이 필드 키(title/slug/content 등) 원문 대신 다국어
라벨로 표시돼야 한다.
- changes_summary.changed_fields 를
$t('sirsoft-page.admin.page.detail.versions.field_labels.<field>') 로 매핑.
14 (버전 복원 후 SEO 캐시 무효화):
- 특정 버전으로 복원하면 title/content/seo_meta 가 이전 버전으로 되돌아가므로, 수정과
동일하게 페이지 상세 SEO 캐시가 즉시 갱신돼야 한다.
- 회귀: SeoPageCacheListener 가 after_restore 를 미구독해 복원 후에도 봇에 복원 전 버전이
잔존 → after_restore 를 onPageChange 로 구독 추가(수정/삭제와 동일 무효화 정책).
axes:
url_context: [public]
form_mode: [create, edit]
upload_count: [single, multiple]
page_change: [create, update, delete, restore]
cached_entry: [layout_tagged, url_only]
upload_validation: [size_exceeded, ext_not_allowed, count_exceeded, valid]
effects:
- admin_detail_and_public_both_return_public_hash_url
- temp_attachments_linked_in_upload_order_1_to_n
- edit_mode_drag_persists_via_reorder_api
- create_form_clears_stale_attachments_via_form_attachments_binding
- create_form_clears_stale_title_slug_and_slug_check_state
- slug_hint_url_preview_falls_back_to_example_on_empty_slug
- page_change_invalidates_page_detail_url_by_singular_pattern
- page_change_invalidates_url_only_cached_detail_without_layout_tag
- page_change_invalidates_page_show_layout_cache
- page_change_invalidates_home_and_search_and_sitemap_cache
- page_restore_invalidates_page_detail_seo_cache
- page_restore_creates_new_version_and_preserves_history
- page_version_changed_fields_render_localized_labels
- attachment_size_or_ext_rejection_shows_error_toast
- attachment_count_exceeded_shows_error_toast_and_truncates
- valid_attachment_upload_shows_no_error_toast
test_files:
- modules/_bundled/sirsoft-page/tests/Unit/Services/PageAttachmentServiceTest.php
- modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php
- modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts
- templates/_bundled/sirsoft-basic/src/components/composite/__tests__/FileUploaderLimit.test.ts
- modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-form-reset-on-route-switch.spec.ts
- modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-attachment-order.spec.ts
- modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-version-changed-fields-i18n.spec.ts
notes: |
6-2 는 이커머스 상품 이미지(FileUploader + temp_key 연결 + reorder API)와 동일한 검증된
패턴으로 정렬했다. 생성 모드 업로드 순서 연결은 PageAttachmentServiceTest, 수정 모드 reorder 와
잔존 버그(라우트 전환 시 key 리마운트)는 admin_page_form 레이아웃 렌더 테스트로 확인.
6-1 은 백엔드 Feature 테스트, 12-2 는 리스너 unit 테스트, 13 은 레이아웃 렌더 테스트로 커버.
14 는 리스너 unit 테스트(after_restore 구독 단언) + Admin Feature 테스트(복원 엔드포인트 →
상세 SEO 캐시 무효화 + 새 버전 생성·이력 보존)로 커버.
E2E: 페이지 모듈에 Playwright 인프라(playwright.config.ts + page-auth/page-seed fixture +
playwright:seed-page stub 커맨드)를 이커머스와 동일 패턴으로 신설했다. spec 3건은
form_mode / upload_count / page_change axis 와 1:1 매핑되며, 대상 화면에 data-testid 가
아직 없어 test.describe.skip placeholder 로 등록(각 spec docblock 에 활성화 절차 명시).
실제 회귀 가드는 위 단위/Feature/레이아웃 렌더 테스트가 담당하고, spec 은 data-testid 보강 +
seed 로직 구현 후 활성화된다 (이커머스 product-seo-input-debounce 등과 동일 운용).
@@ -0,0 +1,75 @@
# audit:allow test-scenario-coverage reason: |
# 본 매니페스트는 첨부 서빙(preview 썸네일 / download 파일) 별건 결함의 serving_type ×
# published × viewer cross product 를 기록한다. preview 는 모든 조합에서 공개 서빙(200),
# download 는 published=200 무조건 / unpublished 는 page_reader admin 만 200·나머지 404 로
# 비대칭이다. 실제 회귀 가드는 test_files 의 Feature/레이아웃 테스트가 담당하므로 docblock
# @scenario 전수 마킹 대신 통과 테스트로 커버한다.
# E2E: 관리자 상세(admin_page_detail) 썸네일·다운로드는 페이지 모듈 Playwright 인프라
# 보강 전까지 test.describe.skip placeholder 로 운용한다(기존 admin spec 3건과 동일 운용).
feature: 페이지 첨부 서빙 (썸네일 공개 hash 서빙 + 다운로드 권한 게이트)
description: |
별건(관리자 상세 첨부 썸네일·다운로드 미노출): 커밋 d48c684 가 관리자 화면 첨부 URL 을
auth:sanctum 보호 admin 라우트로 바꿔, 토큰을 실을 수 없는 <img>/<a> 브라우저 직접 GET 이
발행/미발행 무관 401 로 깨지던 회귀를 수정한다. 게시판·이커머스 표준으로 정렬.
서빙 정책:
- preview(썸네일): 공개 hash 서빙. <img> 는 인증 수단이 없어 모든 모듈이 공개로 둔다.
보안은 hash(12자 랜덤) 비추측성 + 미발행 페이지 미노출에 의존. 발행/미발행 무관 200.
- download(파일): 권한 게이트. 발행 첨부는 누구나 200, 미발행 첨부는
sirsoft-page.pages.read(admin) 보유자만 200, 그 외(비로그인/일반회원/무권한 admin) 404.
구현:
- PageAttachment::downloadUrl()/previewUrl() — 공개 hash URL 단일화(admin 분기 제거).
- PageAttachmentResource — urlContext/withContext 컨텍스트 인프라 제거(공개 URL 고정).
- PublicPageAttachmentController::preview — published 가드 제거(공개 서빙).
- PublicPageAttachmentController::download — Request 주입 +
published || user()?->hasPermission('sirsoft-page.pages.read', admin) 게이트.
- admin 첨부 preview/download 라우트·컨트롤러 메서드 제거(공개 라우트로 단일화 후 데드코드).
- admin_page_detail.json / page/show.json — 다운로드 <a href> → downloadAttachment 핸들러
(토큰 fetch → blob) 전환. admin 다운로드 아이콘 arrow-down-tray(Heroicons·FA미존재) → download.
- page/show.json — 이미지 첨부는 preview_url 썸네일(<Img>, is_image 가드)로 표시(관리자 상세와
일관). 일반 파일은 파일 아이콘 유지. preview_url 이 공개 서빙이 되었으므로 백엔드 변경 불요.
수용된 트레이드오프:
- 썸네일 공개 → hash 보유 시 비로그인도 미발행 이미지 썸네일 조회 가능. 파일 다운로드는
권한으로 보호. 게시판·이커머스와 동일 수위(PO 수용).
axes:
serving_type: [preview, download]
published: [published, unpublished]
viewer: [guest, member, page_reader_admin]
effects:
- preview_published_returns_200_for_any_viewer
- preview_unpublished_returns_200_for_guest
- preview_nonexistent_hash_returns_404
- download_published_returns_200_for_guest
- download_unpublished_blocked_404_for_guest
- download_unpublished_blocked_404_for_member
- download_unpublished_allowed_200_for_page_reader_admin
- attachment_urls_are_public_hash_route_even_for_admin_detail
- download_card_uses_downloadAttachment_handler_not_anchor
- download_icon_uses_valid_fa_name_not_heroicon
- user_page_image_attachment_renders_preview_thumbnail
- user_page_non_image_attachment_renders_file_icon
test_files:
- modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php
- modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php
- modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-detail-attachment-download.test.tsx
- templates/_bundled/sirsoft-basic/__tests__/layouts/page-attachment-download-handler.test.tsx
notes: |
회귀 가드 담당:
- preview/download × published × viewer 게이트 → PublicPageControllerTest(Feature).
- 관리자 상세 응답 첨부 URL 이 공개 hash 라우트인지 → PageControllerTest(Feature).
- admin_page_detail 다운로드 카드가 downloadAttachment 핸들러 + 아이콘 name=download →
admin-page-detail-attachment-download.test.tsx(레이아웃 렌더).
- page/show 다운로드가 downloadAttachment 핸들러 + 이미지 첨부 preview_url 썸네일(is_image 가드)
→ page-attachment-download-handler.test.tsx.
E2E: 관리자 상세 썸네일·다운로드 브라우저 검증은 Chrome MCP 실측으로 대체 확인
(id=96 미발행 썸네일 3건 200 + 다운로드 아이콘 4건 렌더 + 다운로드 토큰동반 200,
genuinely unpublished(page_id=10) guest download 404 / preview 200). 페이지 모듈
Playwright 인프라 보강 전까지 전용 spec 은 미작성(기존 admin spec 3건과 동일 운용).
@@ -0,0 +1,67 @@
# audit:allow test-scenario-coverage reason: |
# 본 매니페스트는 미발행 페이지 관리자 미리보기 단일 결함의 권한 × 발행상태
# cross product 를 기록한다. viewer(guest/member/other_admin/page_reader) × published
# (published/unpublished) 8케이스 중 "미리보기 허용" 은 page_reader × unpublished 단 1조합이며,
# 나머지는 기존 동작(발행=200 공개 / 미발행=404) 유지다. 실제 회귀 가드는 test_files 의
# Feature/Unit/레이아웃 테스트가 결함별로 담당하므로 docblock @scenario 전수 마킹 대신
# 통과 테스트로 커버한다.
# E2E: 사용자 화면 미리보기 spec 은 페이지 모듈 Playwright 인프라(page-auth/page-seed fixture)
# 를 재사용하되, playwright:seed-page 시드 로직 + 대상 화면 data-testid 보강 전까지
# test.describe.skip placeholder 로 운용한다(기존 admin spec 3건과 동일 운용).
feature: 미발행 페이지 관리자 미리보기 (권한자만 사용자 화면 노출 + 미리보기 배너 + 첨부 admin URL)
description: |
미발행 페이지는 관리자여도 사용자 화면(/page/{slug})에 접근할 수 없던 결함을 수정한다.
게이트 정책:
- 비로그인·일반 회원: 미발행 페이지 → 404 유지 (비공개).
- 페이지 조회 권한(sirsoft-page.pages.read, admin) 보유자: 미발행 페이지 → 200 미리보기.
- pages.read 없는 다른 모듈 admin: 404 (최소권한 — 관리자 상세 라우트와 동일 게이트).
- 발행 페이지: 모두 200 (기존 동작 무변경).
구현:
- PageService::getPublishedPageBySlug($slug, $allowUnpublished) — 허용 플래그로 미발행 반환.
- PublicPageController::show — optional.sanctum 로 인증된 사용자의 hasPermission(pages.read, admin)
판정 후 플래그 전달. 권한 판정은 컨트롤러, Service 는 플래그만 수신(게시판 PostController 패턴).
- PublicPageResource::withPreview — is_preview 노출. (첨부 URL 컨텍스트 분기는 별건
page-attachment-serving.yaml 에서 공개 hash 라우트로 단일화되어 제거됨.)
- page/show.json(sirsoft-basic) — is_preview=true 일 때 "미발행 페이지 미리보기 중" 배너 노출.
안전:
- SEO 캐시 오염 없음: 봇(게스트)은 미발행에 여전히 404 → 미리보기 콘텐츠가 봇 캐시에 안 들어감.
- 정보 노출 최소: published 전체가 아닌 is_preview(bool)만 추가 노출.
- 미발행 첨부 파일 다운로드 보호: 첨부 서빙은 공개 hash 라우트지만 다운로드는 권한 게이트로
미발행+비권한자를 404 차단(별건 page-attachment-serving.yaml).
axes:
viewer: [guest, member, other_admin, page_reader]
published: [published, unpublished]
effects:
- guest_sees_published_page
- guest_gets_404_for_unpublished_page
- member_gets_404_for_unpublished_page
- other_admin_without_pages_read_gets_404_for_unpublished_page
- page_reader_admin_previews_unpublished_page_with_200
- preview_response_exposes_is_preview_true_only_for_unpublished_preview
- preview_attachments_use_public_hash_url
- published_page_attachments_use_public_hash_url_even_for_admin
- preview_banner_renders_when_is_preview_true
- preview_banner_hidden_for_published_or_guest
test_files:
- modules/_bundled/sirsoft-page/tests/Feature/User/PublicPageControllerTest.php
- modules/_bundled/sirsoft-page/tests/Unit/Services/PageServiceTest.php
- templates/_bundled/sirsoft-basic/__tests__/layouts/page-show-preview-banner.test.tsx
- modules/_bundled/sirsoft-page/tests/Playwright/specs/user/unpublished-admin-preview.spec.ts
notes: |
회귀 가드 담당:
- 게이트(권한 4변종 × 발행상태) + is_preview 노출 + 첨부 컨텍스트 URL → PublicPageControllerTest(Feature).
- getPublishedPageBySlug 플래그 동작 격리 → PageServiceTest(Unit).
- 배너 if=is_preview 의존 + 다국어 키 + 위치 → page-show-preview-banner.test.tsx(레이아웃 렌더).
E2E: 사용자 화면 미리보기 spec(unpublished-admin-preview.spec.ts)은 페이지 모듈 Playwright
인프라(page-auth fixture 재사용, page-seed fixture)를 사용한다. playwright:seed-page 시드 로직과
대상 화면 data-testid 보강 전까지 test.describe.skip placeholder 로 등록하며(기존 admin spec 3건과
동일 운용), 실제 회귀 가드는 위 Feature/Unit/레이아웃 렌더 테스트가 담당한다.
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-06
### Changed
- 첨부파일을 허용 개수보다 많이 선택했을 때의 안내 문구를 "이미지는 최대 N장까지…"에서 "최대 N개까지 첨부할 수 있습니다"로 바꿔, 이미지뿐 아니라 문서 등 일반 파일을 올리는 화면에서도 자연스럽게 표시되도록 했습니다.
## [1.0.0] - 2026-07-01
### Added
@@ -7,7 +7,7 @@
"deleting": "Deleting...",
"drop_or_click": "Drag and drop files or click to upload",
"upload_limit": "Max {{maxFiles}} files, {{maxSize}}MB each",
"upload_limit_exceeded": "You can upload up to {{max}} images.",
"upload_limit_exceeded": "You can attach up to {{max}} files.",
"attached_count": "{{count}}/{{max}} attached",
"max_size": "Max {{size}}MB",
"drag_to_reorder": "Drag to reorder",
@@ -7,7 +7,7 @@
"deleting": "삭제하는 중...",
"drop_or_click": "파일을 드래그하거나 클릭하여 업로드",
"upload_limit": "최대 {{maxFiles}}개, 개당 {{maxSize}}MB",
"upload_limit_exceeded": "이미지는 최대 {{max}}장까지 업로드할 수 있습니다.",
"upload_limit_exceeded": "최대 {{max}}개까지 첨부할 수 있습니다.",
"attached_count": "{{count}}/{{max}}개 첨부됨",
"max_size": "최대 {{size}}MB",
"drag_to_reorder": "드래그하여 순서 변경",
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
"version": "1.0.0",
"version": "1.0.1",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-06
### Added
- 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때, 미발행 상태임을 알리는 안내 표시를 추가했습니다.
### Fixed
- 게시글 첨부파일을 허용 개수보다 많이 선택하면, 초과분이 아무 안내 없이 사라지지 않고 "최대 N개까지 첨부할 수 있습니다" 안내 메시지가 표시됩니다.
## [1.0.0] - 2026-07-01
### Added
@@ -0,0 +1,111 @@
/**
* @file page-attachment-download-handler.test.tsx
* @description 사용자 페이지 상세(page/show)의 첨부 다운로드가 토큰 동반 핸들러로 전환됐는지 회귀
* (별건: 관리자 상세 첨부 썸네일·다운로드 미노출 — 사용자 화면도 동일 표준으로 정렬).
*
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
*
* 결함: 다운로드가 <A href="{{att_item.download_url}}"> 브라우저 직접 링크였다.
* download_url 은 권한 게이트(미발행)를 가진 공개 라우트이며, <a> 네비게이션에는
* 토큰이 실리지 않아 권한 관리자의 미발행 첨부 다운로드가 404 로 막혔다.
*
* 수정: 다운로드 버튼을 downloadAttachment 핸들러(토큰 동반 fetch → blob)로 전환.
*
* @scenario surface=page_show
* @effects download_uses_handler_not_anchor
*/
import { describe, it, expect } from 'vitest';
import pageShow from '../../layouts/page/show.json';
type Node = Record<string, unknown> & { children?: unknown; actions?: unknown };
function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] {
const result: Node[] = [];
const walk = (cur: unknown): void => {
if (Array.isArray(cur)) {
cur.forEach(walk);
return;
}
if (cur && typeof cur === 'object') {
const obj = cur as Node;
if (predicate(obj)) {
result.push(obj);
}
Object.values(obj).forEach(walk);
}
};
walk(node);
return result;
}
describe('별건 — 사용자 페이지 상세(page/show) 첨부 다운로드 핸들러 전환', () => {
it('첨부 다운로드는 download_url 을 가리키는 <A href> 직접 링크가 아니다', () => {
const anchors = collectNodes(
pageShow,
(n) =>
n.name === 'A' &&
typeof (n.props as Record<string, unknown> | undefined)?.href === 'string' &&
String((n.props as Record<string, unknown>).href).includes('download_url'),
);
expect(anchors.length).toBe(0);
});
it('첨부 다운로드는 downloadAttachment 핸들러 click 액션을 사용한다', () => {
const handlerActions = collectNodes(pageShow, (n) => {
const actions = (n.actions ?? []) as Array<Record<string, unknown>>;
return (
Array.isArray(actions) &&
actions.some((a) => a.type === 'click' && a.handler === 'downloadAttachment')
);
});
expect(handlerActions.length).toBeGreaterThan(0);
const node = handlerActions[0];
const actions = (node.actions ?? []) as Array<Record<string, unknown>>;
const clickAction = actions.find(
(a) => a.type === 'click' && a.handler === 'downloadAttachment',
);
const params = (clickAction?.params ?? {}) as Record<string, unknown>;
expect(params.url).toContain('download_url');
expect(params.filename).toContain('original_filename');
});
});
describe('별건 — 사용자 페이지 상세(page/show) 이미지 첨부 썸네일', () => {
it('이미지 첨부는 preview_url 을 src 로 하는 <Img> 썸네일을 렌더한다', () => {
const thumbs = collectNodes(pageShow, (n) => {
const props = (n.props ?? {}) as Record<string, unknown>;
return (
n.name === 'Img' &&
typeof props.src === 'string' &&
String(props.src).includes('att_item') &&
String(props.src).includes('preview_url')
);
});
expect(thumbs.length).toBeGreaterThan(0);
});
it('썸네일은 is_image 조건으로만 렌더된다 (일반 파일은 아이콘)', () => {
// preview_url <Img> 를 감싸는 노드는 is_image 참일 때만 렌더되어야 한다
const guarded = collectNodes(pageShow, (n) => {
const ifCond = typeof n.if === 'string' ? (n.if as string) : '';
const hasImgChild =
collectNodes(n.children, (c) => {
const props = (c.props ?? {}) as Record<string, unknown>;
return (
c.name === 'Img' &&
typeof props.src === 'string' &&
String(props.src).includes('preview_url')
);
}).length > 0;
return hasImgChild && ifCond.includes('att_item') && ifCond.includes('is_image');
});
expect(guarded.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,79 @@
/**
* @file page-show-preview-banner.test.tsx
* @description 미발행 페이지 관리자 미리보기 배너 (이슈 #424-15)
*
* 정책: 페이지 조회 권한 관리자가 사용자 화면에서 미발행 페이지를 미리볼 때,
* 상단에 "미발행 페이지 미리보기 중" 안내 배너를 노출한다. 백엔드(PublicPageResource)가
* 미발행+권한자 응답에만 is_preview=true 를 내려주므로, 배너의 if 는 그 값에 의존한다.
* 발행 페이지·게스트 응답은 is_preview=false → 배너 미노출.
*
* 검증 방식: 실제 page/show.json 레이아웃 트리 직접 분석 (DOM 비의존).
* - 배너 블록이 존재하고 if 가 page.data.is_preview 에 의존하는지 고정
* - 배너 문구가 다국어 키($t:user.page.preview_banner)로 렌더되는지 고정
*/
import { describe, it, expect } from 'vitest';
import pageShow from '../../layouts/page/show.json';
type Node = Record<string, unknown> & { children?: unknown };
/**
* 트리를 재귀 순회하며 조건을 만족하는 모든 노드를 수집한다.
*/
function collectNodes(node: unknown, predicate: (n: Node) => boolean): Node[] {
const result: Node[] = [];
const walk = (cur: unknown): void => {
if (Array.isArray(cur)) {
cur.forEach(walk);
return;
}
if (cur && typeof cur === 'object') {
const obj = cur as Node;
if (predicate(obj)) {
result.push(obj);
}
Object.values(obj).forEach(walk);
}
};
walk(node);
return result;
}
/**
* id 로 노드를 찾는다.
*/
function findById(root: unknown, id: string): Node | undefined {
return collectNodes(root, (n) => n.id === id)[0];
}
describe('이슈 #424-15 — 미발행 페이지 미리보기 배너 (page/show.json)', () => {
it('미리보기 배너 블록(page_preview_banner)이 존재한다', () => {
const banner = findById(pageShow, 'page_preview_banner');
expect(banner).toBeDefined();
});
it('배너의 if 가 page.data.is_preview 에 의존한다 (발행/게스트는 미노출)', () => {
const banner = findById(pageShow, 'page_preview_banner');
expect(typeof banner?.if).toBe('string');
expect(banner?.if as string).toContain('is_preview');
// 단일 바인딩 형태 {{...}} 여야 엔진이 식으로 평가 (CLAUDE.md if 규칙)
expect((banner?.if as string).trim()).toMatch(/^\{\{.*\}\}$/);
});
it('배너 문구가 다국어 키로 렌더된다 (하드코딩 금지)', () => {
const banner = findById(pageShow, 'page_preview_banner');
const spans = collectNodes(banner, (n) => n.name === 'Span' && typeof n.text === 'string');
const texts = spans.map((s) => s.text as string);
expect(texts).toContain('$t:user.page.preview_banner');
});
it('배너가 본문 카드(page_content_card) 내부, 제목 영역보다 앞에 위치한다', () => {
const card = findById(pageShow, 'page_content_card');
expect(card).toBeDefined();
const children = card?.children as Node[];
expect(Array.isArray(children)).toBe(true);
// 첫 자식이 미리보기 배너여야 함 (제목/발행일보다 위)
expect(children[0]?.id).toBe('page_preview_banner');
});
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@
"delete_confirm_message": "Are you sure you want to delete this file?",
"deleting": "Deleting...",
"attached_count": "{{count}}/{{max}} attached",
"upload_limit_exceeded": "You can attach up to {{max}} files.",
"max_size": "Max {{size}}MB",
"reorder_failed": "Failed to reorder files",
"drag_to_reorder": "Drag to reorder",
@@ -51,7 +51,8 @@
"published_at": "Published",
"create_page": "Create Page",
"attachments": "Attachments",
"download": "Download"
"download": "Download",
"preview_banner": "Previewing an unpublished page"
},
"mypage": {
"page_title": "My Page",
@@ -5,6 +5,7 @@
"delete_confirm_message": "이 파일을 삭제하시겠습니까?",
"deleting": "삭제 중...",
"attached_count": "{{count}}/{{max}}개 첨부됨",
"upload_limit_exceeded": "최대 {{max}}개까지 첨부할 수 있습니다.",
"max_size": "최대 {{size}}MB",
"reorder_failed": "파일 순서 변경에 실패했습니다",
"drag_to_reorder": "드래그하여 순서 변경",
@@ -51,7 +51,8 @@
"published_at": "발행일",
"create_page": "페이지 생성",
"attachments": "첨부파일",
"download": "다운로드"
"download": "다운로드",
"preview_banner": "미발행 페이지 미리보기 중입니다"
},
"mypage": {
"page_title": "마이페이지",
@@ -80,6 +80,30 @@
}
},
"children": [
{
"id": "page_preview_banner",
"type": "basic",
"name": "Div",
"if": "{{page?.data?.is_preview}}",
"props": {
"className": "flex items-center gap-2 mb-4 px-4 py-3 rounded-md bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400 text-sm"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "eye",
"className": "text-amber-600 dark:text-amber-400 text-sm"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:user.page.preview_banner"
}
]
},
{
"type": "basic",
"name": "Div",
@@ -252,10 +276,32 @@
},
"children": [
{
"comment": "이미지 썸네일 (이미지 파일인 경우) - preview_url 공개 서빙",
"type": "basic",
"name": "Div",
"if": "{{att_item.is_image}}",
"props": {
"className": "flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-gray-100 dark:bg-gray-800"
},
"children": [
{
"type": "basic",
"name": "Img",
"props": {
"src": "{{att_item.preview_url}}",
"alt": "{{att_item.original_filename}}",
"className": "w-full h-full object-cover"
}
}
]
},
{
"comment": "파일 아이콘 (일반 파일인 경우)",
"type": "basic",
"name": "Icon",
"if": "{{!att_item.is_image}}",
"props": {
"name": "{{att_item.is_image ? 'image' : 'file'}}",
"name": "file",
"className": "text-gray-400 text-sm flex-shrink-0"
}
},
@@ -271,11 +317,21 @@
},
{
"type": "basic",
"name": "A",
"name": "Button",
"props": {
"href": "{{att_item.download_url}}",
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline flex-shrink-0 no-underline"
"type": "button",
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline flex-shrink-0 bg-transparent border-0 cursor-pointer"
},
"actions": [
{
"type": "click",
"handler": "downloadAttachment",
"params": {
"url": "{{att_item.download_url}}",
"filename": "{{att_item.original_filename}}"
}
}
],
"children": [
{
"type": "basic",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-basic",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-basic",
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-basic",
"version": "1.0.0",
"version": "1.0.1",
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
"type": "module",
"main": "dist/components.js",
@@ -207,9 +207,17 @@ export function useFileUploader(options: UseFileUploaderOptions): UseFileUploade
const handleFiles = useCallback(
async (selectedFiles: FileList) => {
const totalCount = existingFiles.length + pendingFiles.length;
const remainingSlots = maxFiles - totalCount;
const remainingSlots = Math.max(0, maxFiles - totalCount);
const filesToAdd = Array.from(selectedFiles).slice(0, remainingSlots);
// 개수 상한 초과분은 조용히 절단하지 않고 안내 (개수 초과 silent 무시 방지)
if (selectedFiles.length > remainingSlots) {
const overflow = Array.from(selectedFiles).slice(remainingSlots);
const message = t('attachment.upload_limit_exceeded', { max: maxFiles });
// 잘린 첫 파일을 대표로 콜백에 전달 (없으면 무시)
onUploadError?.(message, overflow[0] ?? selectedFiles[0]);
}
for (const file of filesToAdd) {
// 크기 검증
if (file.size > maxSize * 1024 * 1024) {
@@ -0,0 +1,135 @@
/**
* FileUploader 개수 상한 초과 안내 테스트
*
* maxFiles 를 초과해 파일을 선택하면 초과분을 조용히 버리지 않고
* onUploadError 콜백으로 안내한다. (admin_basic 과 동일 동작 — 공통 문구)
*
* @vitest-environment jsdom
* @module composite/__tests__/FileUploaderLimit.test
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useFileUploader } from '../FileUploader/useFileUploader';
import type { Attachment } from '../FileUploader/types';
// G7Core 모킹 (useFileUploader 가 모듈 스코프에서 window.G7Core 를 캐싱하므로
// 새 객체로 교체하지 않고 기존 객체에 프로퍼티를 직접 추가)
beforeEach(() => {
const listeners = new Map<string, Set<Function>>();
if (!(window as any).G7Core) {
(window as any).G7Core = {};
}
const g7 = (window as any).G7Core;
g7.componentEvent = {
on: vi.fn((event: string, callback: Function) => {
if (!listeners.has(event)) {
listeners.set(event, new Set());
}
listeners.get(event)!.add(callback);
return () => {
listeners.get(event)?.delete(callback);
};
}),
emit: vi.fn(),
};
g7.createLogger = vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}));
});
vi.mock('browser-image-compression', () => ({
default: vi.fn((file: File) => Promise.resolve(file)),
}));
URL.createObjectURL = vi.fn(() => 'blob:mock-url');
URL.revokeObjectURL = vi.fn();
describe('FileUploader - 개수 상한 초과 안내 (useFileUploader 훅)', () => {
const defaultEndpoints = {
upload: '/api/attachments',
delete: '/api/attachments/:id',
reorder: '/api/attachments/reorder',
};
const STABLE_EMPTY_FILES: Attachment[] = [];
const STABLE_EMPTY_ROLE_IDS: number[] = [];
function makeFileList(files: File[]): FileList {
return {
...files,
length: files.length,
item: (i: number) => files[i] ?? null,
} as unknown as FileList;
}
it('maxFiles 초과 시 onUploadError 로 안내하고 상한까지만 추가한다', async () => {
const onUploadError = vi.fn();
const { result } = renderHook(() =>
useFileUploader({
collection: 'test',
maxFiles: 2,
maxSize: 10,
maxConcurrentUploads: 3,
roleIds: STABLE_EMPTY_ROLE_IDS,
autoUpload: false,
initialFiles: STABLE_EMPTY_FILES,
confirmBeforeRemove: false,
onUploadError,
endpoints: defaultEndpoints,
})
);
// 상한(2)보다 많은 3개 선택
const files = [
new File(['1'], 'a.png', { type: 'image/png' }),
new File(['2'], 'b.png', { type: 'image/png' }),
new File(['3'], 'c.png', { type: 'image/png' }),
];
await act(async () => {
await result.current.handleFiles(makeFileList(files));
});
// 초과 안내 콜백 1회 (조용히 버리지 않음)
expect(onUploadError).toHaveBeenCalledTimes(1);
// 상한까지만 추가 (2개)
expect(result.current.pendingFiles).toHaveLength(2);
});
it('상한 이내로 선택하면 onUploadError 를 발화하지 않는다 (오발화 없음)', async () => {
const onUploadError = vi.fn();
const { result } = renderHook(() =>
useFileUploader({
collection: 'test',
maxFiles: 5,
maxSize: 10,
maxConcurrentUploads: 3,
roleIds: STABLE_EMPTY_ROLE_IDS,
autoUpload: false,
initialFiles: STABLE_EMPTY_FILES,
confirmBeforeRemove: false,
onUploadError,
endpoints: defaultEndpoints,
})
);
const files = [
new File(['1'], 'a.png', { type: 'image/png' }),
new File(['2'], 'b.png', { type: 'image/png' }),
];
await act(async () => {
await result.current.handleFiles(makeFileList(files));
});
expect(onUploadError).not.toHaveBeenCalled();
expect(result.current.pendingFiles).toHaveLength(2);
});
});
@@ -5,7 +5,7 @@
"ko": "Basic",
"en": "Basic"
},
"version": "1.0.0",
"version": "1.0.1",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 사용자 템플릿",