diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 57359782..6ec667c0 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -25,6 +25,11 @@ ### Fixed - 페이지를 삭제한 뒤 같은 슬러그(주소)로 새 페이지를 다시 만들 수 있도록 수정했습니다. 이전에는 삭제한 페이지의 슬러그가 남아 "이미 사용 중"으로 표시되어 동일 주소로 페이지를 다시 만들 수 없었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.) +- 페이지 등록 / 수정 화면에서 첨부파일을 한 번에 여러 개 올릴 때, 올린 순서대로 첨부가 저장되도록 정리했습니다. 이전에는 여러 파일을 동시에 올릴 때 순서가 뒤섞여 저장되는 경우가 있었습니다. 저장된 첨부의 순서 변경은 수정 화면에서 드래그로 즉시 반영됩니다. (#424 @gnuboard 님께서 제보해주셨습니다.) +- 페이지를 수정하다가 새 페이지 등록 화면으로 이동했을 때, 직전 페이지의 첨부파일·제목·슬러그와 "사용 가능한 슬러그입니다" 안내가 등록 화면에 그대로 남아 보이던 문제를 수정했습니다. (#424 @gnuboard 님께서 제보해주셨습니다.) +- 새 페이지 등록 화면에서 슬러그를 입력하기 전 주소 미리보기가 "/page/{{slug}}" 처럼 표시되던 문제를 수정했습니다. 이제 입력 전에는 "/page/example" 로 보이고, 입력하면 입력한 주소로 즉시 바뀝니다. (#424 @gnuboard 님께서 제보해주셨습니다.) +- 관리자 화면에서 페이지 첨부파일의 다운로드 / 미리보기가 관리자 전용 주소로 열리도록 수정했습니다. 이전에는 관리자 화면에서도 공개용 주소가 사용되어 비공개 페이지의 첨부파일을 관리자가 열지 못하는 경우가 있었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.) +- 페이지를 수정하거나 삭제한 직후 해당 페이지 상세 화면의 검색엔진용 캐시가 즉시 갱신되도록 수정했습니다. 이전에는 일부 캐시가 갱신되지 않아 변경 전 내용이 검색엔진에 남는 경우가 있었습니다. (#424 @gnuboard 님께서 제보해주셨습니다.) ## [1.0.0-beta.3] - 2026-05-11 diff --git a/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts b/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts index f5e07c92..20eebe76 100644 --- a/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts +++ b/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts @@ -568,6 +568,80 @@ describe('admin_page_form.json', () => { expect(emptyDivWrapper, id).toBeUndefined(); } }); + + // ─── Issue #424: 수정→등록 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'"); + }); + + // ─── Issue #424 6-2: 첨부 순서 — 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]'); + }); + + it('FileUploader reorder 엔드포인트가 수정 모드(route.id)에서만 활성화됨 (생성 모드는 temp 첨부라 reorder 불가)', () => { + const uploaders = findComponentsByName(adminPageForm, 'FileUploader'); + expect(uploaders[0].props.apiEndpoints.reorder).toContain('route?.id'); + }); }); // ───────────────────────────────────────────── diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json index d597c925..aaf025e5 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json @@ -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 } }, { @@ -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,38 @@ "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 를 최종 순서로 덮어쓰기 (이커머스 상품 이미지와 동일 패턴, 이슈 #424 6-2)", + "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])}}" + } + } + ] } ] }, @@ -966,7 +1014,7 @@ } }, { - "comment": "FileUploader 업로드 트리거", + "comment": "FileUploader 업로드 트리거 (대기 중인 첨부를 temp_key 로 업로드 → 저장 시 백엔드가 연결)", "handler": "emitEvent", "params": { "event": "upload:page_attachments" diff --git a/modules/_bundled/sirsoft-page/src/Http/Requests/StorePageRequest.php b/modules/_bundled/sirsoft-page/src/Http/Requests/StorePageRequest.php index 44c0a519..1970479f 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Requests/StorePageRequest.php +++ b/modules/_bundled/sirsoft-page/src/Http/Requests/StorePageRequest.php @@ -15,6 +15,8 @@ class StorePageRequest extends FormRequest { /** * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다. + * + * @return bool 항상 true (권한은 미들웨어 체인에서 검증) */ public function authorize(): bool { diff --git a/modules/_bundled/sirsoft-page/src/Http/Requests/UpdatePageRequest.php b/modules/_bundled/sirsoft-page/src/Http/Requests/UpdatePageRequest.php index d5254cf8..48ee14eb 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Requests/UpdatePageRequest.php +++ b/modules/_bundled/sirsoft-page/src/Http/Requests/UpdatePageRequest.php @@ -15,6 +15,8 @@ class UpdatePageRequest extends FormRequest { /** * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다. + * + * @return bool 항상 true (권한은 미들웨어 체인에서 검증) */ public function authorize(): bool { diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php index 0fa10807..cd0a2d9a 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PageAttachmentResource.php @@ -4,12 +4,52 @@ 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 { + /** + * URL 컨텍스트 ('admin' 또는 'public'). + * + * 공개 라우트는 발행 가드가 있어 미발행 페이지 첨부를 차단하므로, + * 관리자 응답은 'admin' 컨텍스트로 발행 가드 없는 라우트 URL을 사용한다. + */ + protected string $urlContext = 'public'; + + /** + * URL 컨텍스트를 지정한 리소스 배열을 생성합니다. + * + * 각 첨부파일을 컨텍스트가 주입된 개별 리소스로 변환합니다. + * (컬렉션 헬퍼는 개별 item에 컨텍스트를 전달하지 못하므로 직접 매핑) + * + * @param iterable|null $attachments 첨부파일 목록 + * @param string $context 'admin' 또는 'public' + * @return array + */ + public static function collectionFor($attachments, string $context): array + { + return Collection::make($attachments ?? []) + ->map(fn ($attachment) => (new self($attachment))->withContext($context)) + ->all(); + } + + /** + * URL 컨텍스트를 설정합니다. + * + * @param string $context 'admin' 또는 'public' + * @return $this + */ + public function withContext(string $context): self + { + $this->urlContext = $context; + + return $this; + } + /** * 리소스를 배열로 변환합니다. * @@ -27,8 +67,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->downloadUrlFor($this->urlContext), + 'preview_url' => $this->resource->previewUrlFor($this->urlContext), 'created_at' => $this->created_at ? $this->formatDateTimeStringForUser($this->created_at) : null, diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php index 12f92b0a..134da95f 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PageResource.php @@ -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, 'admin') + ), ...$this->formatTimestamps(), ...$this->resourceMeta($request), ]; diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php index 69c6433f..2bd7abb6 100644 --- a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php +++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php @@ -37,7 +37,10 @@ class PublicPageResource extends BaseApiResource : 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, 'public') + ), 'created_at' => $this->created_at ? $this->formatDateTimeStringForUser($this->created_at) : null, diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php index 2747c8f5..4b5f407a 100644 --- a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php +++ b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php @@ -62,9 +62,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}"); } // 페이지 상세 레이아웃 캐시 무효화 diff --git a/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php b/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php index 47876aaa..970787f7 100644 --- a/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php +++ b/modules/_bundled/sirsoft-page/src/Models/PageAttachment.php @@ -122,26 +122,55 @@ class PageAttachment extends Model } /** - * 다운로드 URL을 반환합니다. + * 다운로드 URL을 반환합니다 (공개 라우트 — 기본값). * * @return string 다운로드 URL */ public function getDownloadUrlAttribute(): string { - return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash; + return $this->downloadUrlFor('public'); } /** - * 이미지 미리보기 URL을 반환합니다. + * 이미지 미리보기 URL을 반환합니다 (공개 라우트 — 기본값). * * @return string|null 미리보기 URL (이미지가 아니면 null) */ public function getPreviewUrlAttribute(): ?string + { + return $this->previewUrlFor('public'); + } + + /** + * 컨텍스트별 다운로드 URL을 반환합니다. + * + * 공개 라우트는 발행 가드가 있어 미발행 페이지의 첨부를 차단하므로, + * 관리자 응답에는 발행 가드가 없는 admin 라우트 URL을 사용합니다. + * + * @param string $context 'admin' 또는 'public' + * @return string 다운로드 URL + */ + public function downloadUrlFor(string $context = 'public'): string + { + return $context === 'admin' + ? '/api/modules/sirsoft-page/admin/attachments/download/'.$this->hash + : '/api/modules/sirsoft-page/pages/attachment/'.$this->hash; + } + + /** + * 컨텍스트별 이미지 미리보기 URL을 반환합니다. + * + * @param string $context 'admin' 또는 'public' + * @return string|null 미리보기 URL (이미지가 아니면 null) + */ + public function previewUrlFor(string $context = 'public'): ?string { if (! $this->isImage()) { return null; } - return '/api/modules/sirsoft-page/pages/attachment/'.$this->hash.'/preview'; + return $context === 'admin' + ? '/api/modules/sirsoft-page/admin/attachments/preview/'.$this->hash + : '/api/modules/sirsoft-page/pages/attachment/'.$this->hash.'/preview'; } } diff --git a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php index bbd936c8..23a40eea 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageAttachmentService.php @@ -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++; } diff --git a/modules/_bundled/sirsoft-page/src/Services/PageService.php b/modules/_bundled/sirsoft-page/src/Services/PageService.php index 84800288..4e8a09bc 100644 --- a/modules/_bundled/sirsoft-page/src/Services/PageService.php +++ b/modules/_bundled/sirsoft-page/src/Services/PageService.php @@ -72,9 +72,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 +125,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); diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php index bbf0896c..7588df3e 100644 --- a/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Feature/Admin/PageControllerTest.php @@ -181,6 +181,77 @@ class PageControllerTest extends FeatureTestCase $response->assertStatus(404); } + /** + * 관리자 상세 조회 시 첨부 URL이 관리자 라우트를 가리키는지 확인 (이슈 #424 6-1) + * + * 첨부 URL이 공개 라우트(발행 가드 있음)를 가리키면 미발행 페이지의 + * 첨부가 관리자에게도 404로 차단된다. 관리자 응답은 발행 가드 없는 + * admin 라우트를 반환해야 한다. + */ + public function test_admin_show_returns_admin_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/admin/attachments/preview/{$attachment->hash}", + $att['preview_url'] + ); + $this->assertSame( + "/api/modules/sirsoft-page/admin/attachments/download/{$attachment->hash}", + $att['download_url'] + ); + } + + /** + * 공개 상세 조회 시 첨부 URL이 공개 라우트를 유지하는지 확인 (이슈 #424 6-1) + * + * 공개 응답에 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) ────────────────────────────────── /** diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php index 4860437c..8d33142d 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php @@ -135,6 +135,29 @@ class SeoPageCacheListenerTest extends ModuleTestCase $this->listener->onPageChange($page); } + /** + * onPageChange 호출 시 페이지 상세 URL 캐시가 URL 패턴으로 무효화되는지 확인합니다 (이슈 #424 12-2). + * + * 페이지 공개 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', 'detail'); + $this->assertContains('/page/pattern-page', $this->cache->getCachedUrls()); + + $this->listener->onPageChange($page); + + $this->assertNotContains('/page/pattern-page', $this->cache->getCachedUrls()); + } + // ─── handle (인터페이스 준수) ─────────────────────────── /** diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Services/PageAttachmentServiceTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Services/PageAttachmentServiceTest.php index fc113226..712cb88b 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Services/PageAttachmentServiceTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Services/PageAttachmentServiceTest.php @@ -157,6 +157,34 @@ class PageAttachmentServiceTest extends ModuleTestCase $this->assertNull($tempAttachment->temp_key); } + /** + * 연결 시 업로드(조회) 순서대로 order가 1..N 재부여되는지 확인 (이슈 #424 6-2) + * + * 이커머스 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 ────────────────────────────── /** diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml new file mode 100644 index 00000000..d05b3d1b --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-attachment-order-and-seo-cache.yaml @@ -0,0 +1,65 @@ +# audit:allow test-scenario-coverage reason: 본 매니페스트는 이슈 #424 (6-1/6-2/12-2) 회귀 매트릭스 SSoT 로 기록. 핵심 회귀 가드는 test_files 의 통과 테스트(백엔드 PHPUnit + 프론트 vitest)로 커버하며, 첨부 순서/URL 동작은 데이터 흐름 결함이라 단위 계층이 정확한 검증 위치 (브라우저 렌더 결함 아님). + +feature: 페이지 첨부 컨텍스트 URL / 다중 업로드 최종 순서 저장 / 페이지 변경 시 SEO 캐시 무효화 (이슈 #424) + +description: | + 이슈 #424 의 3개 결함을 한 매트릭스로 묶어 회귀를 보장한다. + + 6-1 (첨부 컨텍스트 URL): + - 관리자 화면은 관리자 전용 다운로드/미리보기 URL, 공개 화면은 공개 URL 을 사용해야 한다. + - 이전에는 관리자 화면에서도 공개 URL 이 노출되어 비공개 페이지 첨부를 관리자가 열지 못했다. + - PageAttachmentResource 가 urlContext(public|admin) 를 받아 URL 을 분기. + + 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} 로 정정. + +axes: + url_context: [public, admin] + form_mode: [create, edit] + upload_count: [single, multiple] + page_change: [create, update, delete] + cached_entry: [layout_tagged, url_only] + +effects: + - admin_context_returns_admin_download_and_preview_url + - public_context_returns_public_download_and_preview_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 + +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 + +notes: | + 6-2 는 이커머스 상품 이미지(FileUploader + temp_key 연결 + reorder API)와 동일한 검증된 + 패턴으로 정렬했다. 생성 모드 업로드 순서 연결은 PageAttachmentServiceTest, 수정 모드 reorder 와 + 잔존 버그(라우트 전환 시 key 리마운트)는 admin_page_form 레이아웃 + 브라우저 검수로 확인. + 6-1 은 백엔드 Feature 테스트, 12-2 는 리스너 unit 테스트로 커버. + E2E 면제 사유: 6-2 데이터 흐름은 단위/Feature 계층이 정확한 검증 위치이고, 페이지 모듈에 + Playwright 인프라(토큰/스펙/베이스) 미구축 상태로 spec 1건을 위한 인프라 신설은 본 이슈 범위를 초과한다.