Merge pull request from gnuboard/chym1217/issue424
fix(page): 페이지 검수 이슈 일괄 수정
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+44
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
+47
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "페이지",
|
||||
"en": "Page"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
|
||||
|
||||
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+89
@@ -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');
|
||||
});
|
||||
});
|
||||
+474
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-39
@@ -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());
|
||||
}
|
||||
|
||||
+22
-11
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+1
-10
@@ -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, []);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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'] } }],
|
||||
});
|
||||
+63
@@ -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 는 활성화 시 구현
|
||||
});
|
||||
});
|
||||
+58
@@ -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$'));
|
||||
});
|
||||
});
|
||||
+82
@@ -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');
|
||||
});
|
||||
});
|
||||
+42
@@ -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('');
|
||||
});
|
||||
});
|
||||
+94
@@ -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) ────
|
||||
|
||||
/**
|
||||
|
||||
+110
@@ -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": "드래그하여 순서 변경",
|
||||
|
||||
+5915
-5915
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
|
||||
|
||||
+111
@@ -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
+18
-17
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
@@ -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",
|
||||
|
||||
+9
-1
@@ -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) {
|
||||
|
||||
+135
@@ -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 기본 사용자 템플릿",
|
||||
|
||||
Reference in New Issue
Block a user