Merge pull request from gnuboard:HeuJung/issue450
feat(board,ecommerce,template): 관리자↔유저 크로스 이동 링크 + 상품코드·게시판 슬러그 라우팅 전환
This commit is contained in:
@@ -4,6 +4,17 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 게시물 조회 화면(목록·상세)에 "유저 화면 보기" 버튼을 추가했습니다. 클릭하면 해당 게시판/게시글의 실제 사용자 화면이 새 탭으로 열립니다.
|
||||
- 사용자 게시판 화면(목록·상세)에 관리자 진입 버튼을 추가했습니다. 게시판 관리 권한이 있는 사용자에게만 "관리자 게시물 조회"·"게시판 관리" 버튼이 보이며, 클릭하면 관리자 화면이 새 탭으로 열립니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 관리자 게시판 설정 화면의 주소가 게시판 번호 대신 게시판 아이디(슬러그) 기준으로 바뀌었습니다 (예: /admin/boards/free/edit). 새 게시판을 만든 직후 이동하는 편집 화면, 목록의 활성화 토글·삭제, 목록에서 여는 수정 화면, 상품 문의 설정의 "관리자 메뉴에 추가"까지 모두 게시판 아이디 기준으로 통일되었습니다. 기존 번호 기반 주소도 계속 동작합니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "modules/sirsoft-board",
|
||||
"description": "Board module for Gnuboard7",
|
||||
"type": "library",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -2170,10 +2170,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "admin 게시판 폼 — route.id 유무로 수정/신규(본문 line 9), _local.activeTab 으로 탭 분기(본문 line 161). 검증 오류는 _local.errors.",
|
||||
"comment": "admin 게시판 폼 — route.slug 유무로 수정/신규(본문 line 9), _local.activeTab 으로 탭 분기(본문 line 161). 검증 오류는 _local.errors.",
|
||||
"scope": {
|
||||
"kind": "route",
|
||||
"match": "*/admin/boards/:id/edit"
|
||||
"match": "*/admin/boards/:slug/edit"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+2
-2
@@ -174,7 +174,7 @@
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/edit"
|
||||
"path": "/admin/boards/{{settings?.data?.inquiry?.board_slug ?? ''}}/edit"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -278,7 +278,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{(board_list_for_inquiry?.data?.data ?? []).find(b => b.slug === (settings?.data?.inquiry?.board_slug ?? ''))?.id}}/add-to-menu",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{settings?.data?.inquiry?.board_slug ?? ''}}/add-to-menu",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @file admin-board-cross-navigation.test.tsx
|
||||
* @description 관리자 게시판 ↔ 유저 게시판 상호 이동 크로스링크 검증 (이슈 #450)
|
||||
*
|
||||
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
|
||||
* 검증 목적:
|
||||
* 1. 관리자 게시물 리스트/상세에 "유저 화면 보기" 링크 존재 (새 탭, 조건 없음)
|
||||
* 2. 링크가 올바른 유저 라우트 href 와 target=_blank 를 사용
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import postsIndex from '../../../../resources/layouts/admin/admin_board_posts_index.json';
|
||||
import postDetail from '../../../../resources/layouts/admin/admin_board_post_detail.json';
|
||||
|
||||
interface LayoutNode {
|
||||
id?: string;
|
||||
name?: string;
|
||||
props?: Record<string, unknown>;
|
||||
children?: LayoutNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 레이아웃 트리에서 id 로 노드를 재귀 탐색 */
|
||||
function findById(node: LayoutNode | LayoutNode[] | undefined, id: string): LayoutNode | null {
|
||||
if (!node) return null;
|
||||
const nodes = Array.isArray(node) ? node : [node];
|
||||
for (const n of nodes) {
|
||||
if (n && typeof n === 'object') {
|
||||
if (n.id === id) return n;
|
||||
for (const key of Object.keys(n)) {
|
||||
const child = (n as Record<string, unknown>)[key];
|
||||
if (Array.isArray(child) || (child && typeof child === 'object')) {
|
||||
const found = findById(child as LayoutNode | LayoutNode[], id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('이슈 #450 — 관리자 게시판 → 유저 게시판 크로스링크', () => {
|
||||
describe('admin_board_posts_index.json (게시물 리스트)', () => {
|
||||
const link = findById(postsIndex as LayoutNode, 'view_user_page_link');
|
||||
|
||||
it('유저 화면 보기 링크 노드가 존재한다', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('A 태그로 유저 게시판 리스트 경로를 새 탭으로 연다', () => {
|
||||
expect(link?.name).toBe('A');
|
||||
expect(link?.props?.href).toBe('/board/{{route.slug}}');
|
||||
expect(link?.props?.target).toBe('_blank');
|
||||
expect(link?.props?.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('노출 조건(if)이 없어 관리자 화면에서 항상 표시된다', () => {
|
||||
expect(link?.if).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin_board_post_detail.json (게시물 상세)', () => {
|
||||
const link = findById(postDetail as LayoutNode, 'view_user_page_link');
|
||||
|
||||
it('유저 화면 보기 링크 노드가 존재한다', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('A 태그로 유저 게시판 상세 경로를 새 탭으로 연다', () => {
|
||||
expect(link?.name).toBe('A');
|
||||
expect(link?.props?.href).toBe('/board/{{route.slug}}/{{route?.id}}');
|
||||
expect(link?.props?.target).toBe('_blank');
|
||||
});
|
||||
|
||||
it('노출 조건(if)이 없어 관리자 화면에서 항상 표시된다', () => {
|
||||
expect(link?.if).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -233,7 +233,8 @@
|
||||
"blind": "Blind",
|
||||
"restore": "Restore",
|
||||
"delete": "Delete",
|
||||
"create": "Create Post"
|
||||
"create": "Create Post",
|
||||
"view_user_page": "View on site"
|
||||
},
|
||||
"author_actions": {
|
||||
"view_user": "View User Info"
|
||||
|
||||
@@ -236,7 +236,8 @@
|
||||
"blind": "블라인드",
|
||||
"restore": "복원",
|
||||
"delete": "삭제",
|
||||
"create": "게시글 추가"
|
||||
"create": "게시글 추가",
|
||||
"view_user_page": "유저 화면 보기"
|
||||
},
|
||||
"author_actions": {
|
||||
"view_user": "사용자 정보 보기"
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
],
|
||||
"extends": "_admin_base",
|
||||
"meta": {
|
||||
"title": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
|
||||
"title": "{{route?.slug ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}",
|
||||
"description": "$t:sirsoft-board.admin.form.description"
|
||||
},
|
||||
"computed": {
|
||||
"isReadOnly": "{{!!route?.id && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
|
||||
"isReadOnly": "{{!!route?.slug && !!boards?.data && boards?.data?.abilities?.can_update !== true}}"
|
||||
},
|
||||
"state": {
|
||||
"activeTab": "section_basic_card",
|
||||
@@ -40,7 +40,7 @@
|
||||
"cache": false,
|
||||
"loading_strategy": "blocking",
|
||||
"params": {
|
||||
"board_id": "{{route?.id}}",
|
||||
"board_slug": "{{route?.slug}}",
|
||||
"copy_id": "{{query?.copy_id}}"
|
||||
},
|
||||
"initLocal": "form",
|
||||
@@ -141,7 +141,7 @@
|
||||
"props": {
|
||||
"className": "page-title"
|
||||
},
|
||||
"text": "{{route?.id ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
|
||||
"text": "{{route?.slug ? '$t:sirsoft-board.admin.form.title_edit' : '$t:sirsoft-board.admin.form.title_create'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -368,7 +368,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.id && !_local.hasChanges)}}",
|
||||
"disabled": "{{_computed.isReadOnly || _local.isSaving || (!!route?.slug && !_local.hasChanges)}}",
|
||||
"className": "btn btn-primary flex-center gap-1.5"
|
||||
},
|
||||
"children": [
|
||||
@@ -403,9 +403,9 @@
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "{{route?.id ? '/api/modules/sirsoft-board/admin/boards/' + route.id : '/api/modules/sirsoft-board/admin/boards'}}",
|
||||
"target": "{{route?.slug ? '/api/modules/sirsoft-board/admin/boards/' + route.slug : '/api/modules/sirsoft-board/admin/boards'}}",
|
||||
"params": {
|
||||
"method": "{{route?.id ? 'PUT' : 'POST'}}",
|
||||
"method": "{{route?.slug ? 'PUT' : 'POST'}}",
|
||||
"body": "{{_local.form}}"
|
||||
},
|
||||
"onSuccess": [
|
||||
@@ -422,14 +422,14 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "{{route?.id ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
|
||||
"message": "{{route?.slug ? '$t:sirsoft-board.admin.board.messages.update_success' : '$t:sirsoft-board.admin.board.messages.create_success'}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "openWindow",
|
||||
"condition": "{{!route?.id}}",
|
||||
"condition": "{{!route?.slug}}",
|
||||
"params": {
|
||||
"path": "{{'/admin/boards/' + result.data.id + '/edit'}}",
|
||||
"path": "{{'/admin/boards/' + result.data.slug + '/edit'}}",
|
||||
"target": "_self"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{row.id}}",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{row.slug}}",
|
||||
"params": {
|
||||
"method": "PUT",
|
||||
"body": {
|
||||
@@ -340,7 +340,7 @@
|
||||
"edit": {
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/boards/{{row.id}}/edit",
|
||||
"path": "/admin/boards/{{row.slug}}/edit",
|
||||
"mergeQuery": false,
|
||||
"query": []
|
||||
}
|
||||
@@ -758,7 +758,7 @@
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{_global.modal_data?.id}}",
|
||||
"target": "/api/modules/sirsoft-board/admin/boards/{{_global.modal_data?.slug}}",
|
||||
"params": {
|
||||
"method": "DELETE",
|
||||
"body": []
|
||||
|
||||
@@ -80,6 +80,33 @@
|
||||
"description": "{{post?.data?.board?.name}}$t:sirsoft-board.admin.posts.page_description_with_board"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "view_user_page_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/board/{{route.slug}}/{{route?.id}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:sirsoft-board.admin.posts.actions.view_user_page",
|
||||
"className": "flex-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-board.admin.posts.actions.view_user_page"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "refresh_button",
|
||||
"type": "basic",
|
||||
|
||||
@@ -112,6 +112,33 @@
|
||||
"className": "flex-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "view_user_page_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/board/{{route.slug}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:sirsoft-board.admin.posts.actions.view_user_page",
|
||||
"className": "flex-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-board.admin.posts.actions.view_user_page"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "refresh_button",
|
||||
"type": "basic",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "*/admin/boards/:id/edit",
|
||||
"path": "*/admin/boards/:slug/edit",
|
||||
"layout": "admin_board_form",
|
||||
"auth_required": true,
|
||||
"meta": {
|
||||
|
||||
@@ -15,6 +15,7 @@ use Modules\Sirsoft\Board\Http\Resources\BoardCollection;
|
||||
use Modules\Sirsoft\Board\Http\Resources\BoardResource;
|
||||
use Modules\Sirsoft\Board\Http\Resources\BoardTypeResource;
|
||||
use Modules\Sirsoft\Board\Http\Resources\PostResource;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Services\BoardService;
|
||||
use Modules\Sirsoft\Board\Services\BoardTypeService;
|
||||
use Modules\Sirsoft\Board\Services\PostService;
|
||||
@@ -69,13 +70,13 @@ class BoardController extends AdminBaseController
|
||||
/**
|
||||
* 게시판 상세 정보를 조회합니다.
|
||||
*
|
||||
* @param int $id 게시판 ID
|
||||
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
|
||||
* @return JsonResponse 게시판 상세 정보 응답
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
public function show(Board $board): JsonResponse
|
||||
{
|
||||
try {
|
||||
$board = $this->boardService->getBoard($id);
|
||||
$board = $this->boardService->getBoard($board->id);
|
||||
|
||||
return $this->successWithResource(
|
||||
'sirsoft-board::messages.boards.fetch_success',
|
||||
@@ -168,13 +169,13 @@ class BoardController extends AdminBaseController
|
||||
* 게시판 정보를 수정합니다.
|
||||
*
|
||||
* @param UpdateBoardRequest $request 게시판 수정 요청
|
||||
* @param int $id 게시판 ID
|
||||
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
|
||||
* @return JsonResponse 수정된 게시판 정보 응답
|
||||
*/
|
||||
public function update(UpdateBoardRequest $request, int $id): JsonResponse
|
||||
public function update(UpdateBoardRequest $request, Board $board): JsonResponse
|
||||
{
|
||||
try {
|
||||
$board = $this->boardService->updateBoard($id, $request->validated());
|
||||
$board = $this->boardService->updateBoard($board->id, $request->validated());
|
||||
|
||||
return $this->successWithResource(
|
||||
'sirsoft-board::messages.boards.update_success',
|
||||
@@ -194,16 +195,16 @@ class BoardController extends AdminBaseController
|
||||
/**
|
||||
* 게시판을 영구 삭제합니다.
|
||||
*
|
||||
* @param int $id 게시판 ID
|
||||
* @param Board $board 라우트 바인딩된 게시판 (slug 또는 ID)
|
||||
* @param Request $request HTTP 요청
|
||||
* @return JsonResponse 삭제 성공 응답
|
||||
*/
|
||||
// audit:allow controller-base-request-injection reason: DELETE 단건 삭제. force_delete 불리언 플래그만 input()으로 읽음 (검증 불필요)
|
||||
public function destroy(int $id, Request $request): JsonResponse
|
||||
public function destroy(Board $board, Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$forceDelete = (bool) $request->input('force_delete', false);
|
||||
$this->boardService->deleteBoard($id, $forceDelete);
|
||||
$this->boardService->deleteBoard($board->id, $forceDelete);
|
||||
|
||||
return $this->success('sirsoft-board::messages.boards.delete_success');
|
||||
} catch (ModelNotFoundException $e) {
|
||||
@@ -227,11 +228,17 @@ class BoardController extends AdminBaseController
|
||||
try {
|
||||
$basicDefaults = g7_module_settings('sirsoft-board', 'basic_defaults', []);
|
||||
|
||||
// 수정 모드
|
||||
if ($request->filled('board_id')) {
|
||||
// 수정 모드 — board_slug(신규, slug 라우팅) 또는 board_id(하위호환) 로 식별
|
||||
if ($request->filled('board_slug') || $request->filled('board_id')) {
|
||||
$request->merge(['include_permissions' => true]);
|
||||
|
||||
$board = $this->boardService->getBoard($request->board_id);
|
||||
$board = $request->filled('board_slug')
|
||||
? $this->boardService->getBoardBySlug($request->board_slug)
|
||||
: $this->boardService->getBoard((int) $request->board_id);
|
||||
|
||||
if (! $board) {
|
||||
return $this->notFound('sirsoft-board::messages.boards.error_404');
|
||||
}
|
||||
// 폼 토글 초기값: 관리자 메뉴 등록 여부를 모델 속성으로 전달
|
||||
$board->is_in_admin_menu = $this->boardService->isInAdminMenu($board);
|
||||
$data = (new BoardResource($board))->toArray($request);
|
||||
@@ -317,14 +324,14 @@ class BoardController extends AdminBaseController
|
||||
/**
|
||||
* 게시판을 관리자 메뉴에 추가합니다.
|
||||
*
|
||||
* @param int $id 게시판 ID
|
||||
* @param Board $board 게시판 모델 (slug/id 라우트 바인딩)
|
||||
* @return JsonResponse 메뉴 추가 결과 응답
|
||||
*/
|
||||
public function addToAdminMenu(int $id): JsonResponse
|
||||
public function addToAdminMenu(Board $board): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Service 계층에 위임
|
||||
$menu = $this->boardService->addToAdminMenu($id);
|
||||
$menu = $this->boardService->addToAdminMenu($board->id);
|
||||
|
||||
return $this->success('sirsoft-board::messages.boards.menu_added_success', ['menu' => $menu]);
|
||||
} catch (MenuAlreadyExistsException $e) {
|
||||
|
||||
@@ -152,6 +152,7 @@ class BoardResource extends BaseApiResource
|
||||
public function toBoardInfoForUser(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'name' => $this->getLocalizedName(),
|
||||
'description' => $this->getLocalizedDescription(),
|
||||
@@ -394,9 +395,11 @@ class BoardResource extends BaseApiResource
|
||||
'can_upload' => "sirsoft-board.{$slug}.attachments.upload",
|
||||
'can_download' => "sirsoft-board.{$slug}.attachments.download",
|
||||
'can_manage' => "sirsoft-board.{$slug}.manager",
|
||||
// 유저 화면에서 관리자 게시판 화면 진입 게이트 (Admin 타입 admin.manage 보유자만 true)
|
||||
'can_access_admin' => "sirsoft-board.{$slug}.admin.manage",
|
||||
];
|
||||
|
||||
// 8개 권한을 일괄 조회 (개별 Permission::where() 8회 → whereIn 1회)
|
||||
// 권한 일괄 조회 (개별 Permission::where() → whereIn 1회)
|
||||
$identifiers = array_values($permissionMap);
|
||||
$permissions = Permission::whereIn('identifier', $identifiers)->get()->keyBy('identifier');
|
||||
|
||||
|
||||
@@ -300,6 +300,9 @@ class PostCollection extends BaseApiCollection
|
||||
'can_download' => $this->checkBoardPermission($slug, 'attachments.download', PermissionType::User),
|
||||
'can_manage' => $canManage,
|
||||
'can_view_deleted' => $canManage,
|
||||
// 유저 화면에서 관리자 게시판 화면으로 진입 가능한지 여부 (Admin 타입 admin.manage 권한 보유자만 true).
|
||||
// 유저용 can_manage(게시판 매니저)와 다른, 관리자 화면 접근 게이트 전용 플래그.
|
||||
'can_access_admin' => $this->checkBoardPermission($slug, 'admin.manage'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ class PostResource extends BaseApiResource
|
||||
private function getBoardInfo(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->board->id,
|
||||
'slug' => $this->board->slug,
|
||||
'name' => $this->board->getLocalizedName(),
|
||||
'type' => $this->board->type,
|
||||
@@ -601,11 +602,19 @@ class PostResource extends BaseApiResource
|
||||
'can_manage' => "sirsoft-board.{$slug}.manager",
|
||||
];
|
||||
|
||||
return collect($permissionMap)
|
||||
$abilities = collect($permissionMap)
|
||||
->mapWithKeys(fn (string $identifier, string $key) => [
|
||||
$key => $this->checkPermissionByIdentifier($identifier),
|
||||
])
|
||||
->toArray();
|
||||
|
||||
// 유저 상세 화면에서만: 관리자 게시판 화면 진입 게이트(Admin 타입 admin.manage).
|
||||
// Admin 요청에서는 can_manage 가 이미 admin.manage 이므로 중복 노출 불필요.
|
||||
if (! $this->isAdminRequest($request)) {
|
||||
$abilities['can_access_admin'] = $this->checkPermissionByIdentifier("sirsoft-board.{$slug}.admin.manage");
|
||||
}
|
||||
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,29 @@ class Board extends Model
|
||||
'max_file_count' => ['label_key' => 'sirsoft-board::activity_log.fields.max_file_count', 'type' => 'number'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 라우트 모델 바인딩 시 슬러그(slug) 또는 숫자 ID로 게시판을 조회합니다.
|
||||
*
|
||||
* 관리자 게시판 URL 은 slug 기준(/admin/boards/:slug/edit)이며,
|
||||
* 숫자 ID 도 하위호환으로 허용합니다.
|
||||
*
|
||||
* @param mixed $value 라우트 파라미터 값 (slug 또는 ID)
|
||||
* @param string|null $field 검색할 필드명
|
||||
* @return Model|null
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
if ($field) {
|
||||
return $this->where($field, $value)->firstOrFail();
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return $this->where('id', $value)->firstOrFail();
|
||||
}
|
||||
|
||||
return $this->where('slug', $value)->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* 팩토리 생성
|
||||
*/
|
||||
|
||||
@@ -373,6 +373,83 @@ class BoardManagementTest extends ModuleTestCase
|
||||
$this->assertTrue($response->json('data.is_active'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 상세를 slug 로 조회할 수 있다 (#450 관리자 라우팅 slug 전환)
|
||||
*/
|
||||
public function test_board_detail_can_be_fetched_by_slug(): void
|
||||
{
|
||||
// Given
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
|
||||
// When: 숫자 id 가 아닌 slug 로 상세 조회
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->getJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}");
|
||||
|
||||
// Then: 동일 게시판 반환
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame($board->id, $response->json('data.id'));
|
||||
$this->assertSame($board->slug, $response->json('data.slug'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판을 slug 로 수정할 수 있다 (#450)
|
||||
*/
|
||||
public function test_board_can_be_updated_by_slug(): void
|
||||
{
|
||||
// Given
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
|
||||
// When: slug 로 PUT
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->putJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}", [
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
// Then
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($board->fresh()->is_active);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터를 board_slug 로 수정 모드 조회할 수 있다 (#450)
|
||||
*/
|
||||
public function test_get_form_data_edit_mode_by_board_slug(): void
|
||||
{
|
||||
// Given
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
|
||||
// When: board_slug 쿼리로 폼 데이터 조회
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->getJson('/api/modules/sirsoft-board/admin/boards/form-data?board_slug='.$board->slug);
|
||||
|
||||
// Then: 해당 게시판 데이터가 로드됨
|
||||
$response->assertStatus(200);
|
||||
$this->assertSame($board->id, $response->json('data.id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판을 slug 로 관리자 메뉴에 추가할 수 있다 (#450 add-to-menu slug 바인딩)
|
||||
*
|
||||
* 라우트 파라미터가 {board} 이므로 컨트롤러가 Board route-model binding 으로
|
||||
* slug 를 해석해야 한다. int $id 로 받으면 slug 문자열이 전달될 때 실패한다.
|
||||
*/
|
||||
public function test_add_to_admin_menu_resolves_board_by_slug(): void
|
||||
{
|
||||
// Given: 활성 게시판
|
||||
$board = Board::factory()->create([
|
||||
'slug' => 'test-'.substr(md5(microtime().'addmenu'), 0, 8),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// When: 숫자 id 가 아닌 slug 로 add-to-menu 호출
|
||||
$response = $this->actingAs($this->adminUser)
|
||||
->postJson("/api/modules/sirsoft-board/admin/boards/{$board->slug}/add-to-menu");
|
||||
|
||||
// Then: slug 가 정상 해석되어 메뉴가 추가됨 (route-model binding 성공)
|
||||
$response->assertStatus(200);
|
||||
$this->assertNotNull($response->json('data.menu'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 부분 업데이트 시 is_active만 변경 가능 테스트
|
||||
*/
|
||||
|
||||
@@ -262,6 +262,122 @@ class PostResourcePermissionTest extends BoardTestCase
|
||||
$this->assertArrayNotHasKey('admin_manage', $abilities);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// can_access_admin 게이트 테스트 (#450)
|
||||
// 유저 화면에서 관리자 게시판 화면 진입 크로스링크 노출 여부
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* User 상세: admin.manage(Admin 타입) 권한 보유자는 can_access_admin = true
|
||||
*
|
||||
* @scenario link=user_board_to_admin, permitted=true
|
||||
* @effects board_admin_gate_true_shows_link
|
||||
*/
|
||||
#[Test]
|
||||
public function user_detail_returns_can_access_admin_true_for_admin_permission_holder(): void
|
||||
{
|
||||
// Given: 게시글 + admin.manage 권한 보유 사용자
|
||||
$postId = $this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'Test Post',
|
||||
'content' => 'Test Content',
|
||||
]);
|
||||
|
||||
// When: 관리 권한 보유자가 유저 라우트로 상세 조회
|
||||
$response = $this->actingAs($this->adminUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
|
||||
|
||||
// Then: 유저 컨텍스트에도 can_access_admin 노출 + true
|
||||
$response->assertStatus(200);
|
||||
$abilities = $response->json('data.abilities');
|
||||
|
||||
$this->assertArrayHasKey('can_access_admin', $abilities);
|
||||
$this->assertTrue($abilities['can_access_admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* User 상세: 일반 사용자(admin.manage 미보유)는 can_access_admin = false
|
||||
*
|
||||
* @scenario link=user_board_to_admin, permitted=false
|
||||
* @effects board_admin_gate_false_hides_link
|
||||
*/
|
||||
#[Test]
|
||||
public function user_detail_returns_can_access_admin_false_for_regular_user(): void
|
||||
{
|
||||
// Given
|
||||
$postId = $this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'Test Post',
|
||||
'content' => 'Test Content',
|
||||
]);
|
||||
|
||||
// When: user 권한만 보유한 사용자가 유저 라우트로 상세 조회
|
||||
$response = $this->actingAs($this->regularUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
|
||||
|
||||
// Then: can_access_admin 은 노출되나 false
|
||||
$response->assertStatus(200);
|
||||
$abilities = $response->json('data.abilities');
|
||||
|
||||
$this->assertArrayHasKey('can_access_admin', $abilities);
|
||||
$this->assertFalse($abilities['can_access_admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* User 목록: 컬렉션 abilities 에도 can_access_admin 노출
|
||||
*/
|
||||
#[Test]
|
||||
public function user_list_collection_exposes_can_access_admin(): void
|
||||
{
|
||||
// Given
|
||||
$this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'List Post',
|
||||
'content' => 'List Content',
|
||||
]);
|
||||
|
||||
// When: 관리 권한 보유자가 유저 라우트로 목록 조회
|
||||
$response = $this->actingAs($this->adminUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
|
||||
|
||||
// Then: 컬렉션 abilities 에 can_access_admin = true
|
||||
$response->assertStatus(200);
|
||||
$abilities = $response->json('data.abilities');
|
||||
|
||||
$this->assertArrayHasKey('can_access_admin', $abilities);
|
||||
$this->assertTrue($abilities['can_access_admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* User 목록/상세 응답의 board 정보에 id 가 포함된다 (#450)
|
||||
*
|
||||
* "게시판 관리" 크로스링크가 /admin/boards/:id/edit 로 이동하려면 board id 필요.
|
||||
*/
|
||||
#[Test]
|
||||
public function user_response_board_info_includes_id(): void
|
||||
{
|
||||
// Given
|
||||
$postId = $this->createPost([
|
||||
'user_id' => $this->postAuthor->id,
|
||||
'title' => 'Board Id Post',
|
||||
'content' => 'Board Id Content',
|
||||
]);
|
||||
|
||||
// When: 목록
|
||||
$listResponse = $this->actingAs($this->regularUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
|
||||
// And: 상세
|
||||
$detailResponse = $this->actingAs($this->regularUser, 'sanctum')
|
||||
->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}");
|
||||
|
||||
// Then: 목록/상세 board 정보 모두 실제 게시판 id 노출
|
||||
$listResponse->assertStatus(200);
|
||||
$this->assertSame($this->board->id, $listResponse->json('data.board.id'));
|
||||
|
||||
$detailResponse->assertStatus(200);
|
||||
$this->assertSame($this->board->id, $detailResponse->json('data.board.id'));
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 소유권 플래그 테스트
|
||||
// ==========================================
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
|
||||
## [1.0.1] - 2026-07-02
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 상품 수정 화면에 "유저 화면 보기" 버튼을 추가했습니다 — 클릭하면 해당 상품의 실제 사용자 상세 화면이 새 탭으로 열립니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 상품 상세 페이지 주소가 상품 번호 대신 상품코드 기준으로 바뀌었습니다 (상품 목록·최근 본 상품·검색·장바구니·주문내역·마이페이지 상품 문의내역 등 모든 상품 링크 포함). 기존 번호 기반 주소도 계속 동작합니다.
|
||||
- 상품 검색에서 상품코드와 상품 번호로도 상품을 찾을 수 있습니다 — 관리자 상품 검색과 사용자 통합검색 모두 지원합니다. 통합검색 결과에서 상품을 클릭하면 상품코드 주소로 이동합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 관리자 주문 상세에서 주문상품을 "구매확정"으로 변경할 때 구매확정 처리가 고객이 직접 구매확정한 경우와 동일하게 이뤄지지 않던 문제를 수정했습니다 — 이제 구매확정 시점이 정확히 기록되어(부분 수량만 구매확정한 경우 포함), 구매확정 기준으로 지급되는 마일리지 적립이 정상적으로 지급되고, 리뷰 작성 기한도 올바르게 계산됩니다. 기존에는 관리자가 구매확정 처리한 주문상품이 마일리지 적립 대상에서 누락되고 리뷰 작성 기한이 계산되지 않을 수 있었습니다. (#61 @miles44229 님께서 제보해주셨습니다.)
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"edit_search": "Edit Search",
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"save_preset": "Save Preset"
|
||||
"save_preset": "Save Preset",
|
||||
"view_user_page": "View on site"
|
||||
},
|
||||
"bulk_apply": "Apply",
|
||||
"bulk_success": "Selected products have been changed.",
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
"edit_search": "검색 편집",
|
||||
"search": "검색",
|
||||
"reset": "초기화",
|
||||
"save_preset": "프리셋 저장"
|
||||
"save_preset": "프리셋 저장",
|
||||
"view_user_page": "유저 화면 보기"
|
||||
},
|
||||
"bulk_apply": "일괄 적용",
|
||||
"bulk_success": "선택한 상품이 일괄 변경되었습니다.",
|
||||
|
||||
+29
-1
@@ -393,7 +393,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "page-header-padded"
|
||||
"className": "page-header-padded flex items-start justify-between gap-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -417,6 +417,34 @@
|
||||
"text": "{{route.itemCode ? '$t:sirsoft-ecommerce.admin.product.description_edit' : '$t:sirsoft-ecommerce.admin.product.description_create'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "view_user_page_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"if": "{{!!route.itemCode}}",
|
||||
"props": {
|
||||
"href": "{{(_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : ('/' + (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop'))) + '/products/' + route.itemCode}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:sirsoft-ecommerce.admin.product.action.view_user_page",
|
||||
"className": "flex-center gap-2 flex-shrink-0 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-ecommerce.admin.product.action.view_user_page"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1914,7 +1914,7 @@
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "{{`/admin/ecommerce/products/${row.product?.id}`}}",
|
||||
"href": "{{`/admin/ecommerce/products/${row.product?.product_code ?? row.product?.id}/edit`}}",
|
||||
"className": "text-blue-600 hover:underline dark:text-blue-400 font-medium text-sm leading-snug"
|
||||
},
|
||||
"text": "{{row.product?.name ?? ''}}"
|
||||
|
||||
+1
-1
@@ -1565,7 +1565,7 @@
|
||||
"edit": {
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/products/{{$args[1].id}}/edit"
|
||||
"path": "/admin/ecommerce/products/{{$args[1].product_code ?? $args[1].id}}/edit"
|
||||
}
|
||||
},
|
||||
"copy": {
|
||||
|
||||
+8
-4
@@ -13,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Http\Requests\Public\PublicProductRecentRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductCollection;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductListResource;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\PublicProductResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductService;
|
||||
|
||||
/**
|
||||
@@ -167,16 +168,19 @@ class ProductController extends PublicBaseController
|
||||
* 공개 상품 상세 정보를 조회합니다.
|
||||
*
|
||||
* 전시상태가 visible인 상품만 조회할 수 있습니다.
|
||||
* 라우트 파라미터는 상품코드(product_code) 기준이며, 숫자 ID도 하위호환으로 허용됩니다
|
||||
* (Product::resolveRouteBinding 이 code/id 를 자동 해석).
|
||||
*
|
||||
* @param int $id 상품 ID
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @return JsonResponse 상품 상세 정보를 포함한 JSON 응답
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
public function show(Product $product): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('products.show', ['product_id' => $id]);
|
||||
$this->logApiUsage('products.show', ['product_id' => $product->id]);
|
||||
|
||||
$product = $this->productService->getDetail($id);
|
||||
// 바인딩된 상품의 id 로 상세(옵션/관계 포함)를 재조회
|
||||
$product = $this->productService->getDetail($product->id);
|
||||
|
||||
if (! $product || $product->display_status->value !== 'visible') {
|
||||
return ResponseHelper::notFound(
|
||||
|
||||
+7
-6
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\StoreInquiryRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductInquiryService;
|
||||
|
||||
/**
|
||||
@@ -24,10 +25,10 @@ class ProductInquiryController extends PublicBaseController
|
||||
/**
|
||||
* 상품 문의 목록 조회
|
||||
*
|
||||
* @param int $productId 상품 ID
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @return JsonResponse 문의 목록 및 board_settings 메타 JSON 응답
|
||||
*/
|
||||
public function index(int $productId): JsonResponse
|
||||
public function index(Product $product): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('inquiry.index');
|
||||
@@ -35,7 +36,7 @@ class ProductInquiryController extends PublicBaseController
|
||||
$page = (int) (request()->query('page', 1));
|
||||
$excludeSecret = filter_var(request()->query('exclude_secret', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$result = $this->inquiryService->getProductInquiries($productId, $perPage, $page, $excludeSecret);
|
||||
$result = $this->inquiryService->getProductInquiries($product->id, $perPage, $page, $excludeSecret);
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
@@ -55,14 +56,14 @@ class ProductInquiryController extends PublicBaseController
|
||||
* 상품 문의 작성
|
||||
*
|
||||
* @param StoreInquiryRequest $request 문의 작성 요청
|
||||
* @param int $productId 상품 ID
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @return JsonResponse 작성된 문의 JSON 응답
|
||||
*/
|
||||
public function store(StoreInquiryRequest $request, int $productId): JsonResponse
|
||||
public function store(StoreInquiryRequest $request, Product $product): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('inquiry.store');
|
||||
$inquiry = $this->inquiryService->createInquiry($productId, $request->validated());
|
||||
$inquiry = $this->inquiryService->createInquiry($product->id, $request->validated());
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+5
-4
@@ -8,6 +8,7 @@ use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\PublicReviewListRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\ProductReviewResource;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductReviewService;
|
||||
|
||||
/**
|
||||
@@ -24,11 +25,11 @@ class ProductReviewController extends PublicBaseController
|
||||
/**
|
||||
* 상품 공개 리뷰 목록 및 별점 통계 조회
|
||||
*
|
||||
* @param Request $request 요청
|
||||
* @param int $productId 상품 ID
|
||||
* @param PublicReviewListRequest $request 요청
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @return JsonResponse 리뷰 목록 및 별점 통계 JSON 응답
|
||||
*/
|
||||
public function index(PublicReviewListRequest $request, int $productId): JsonResponse
|
||||
public function index(PublicReviewListRequest $request, Product $product): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('review.index');
|
||||
@@ -40,7 +41,7 @@ class ProductReviewController extends PublicBaseController
|
||||
: (json_decode($rawOptionFilters, true) ?? []);
|
||||
$perPage = (int) ($filters['per_page'] ?? 10);
|
||||
|
||||
$result = $this->reviewService->getProductReviews($productId, $filters, $perPage);
|
||||
$result = $this->reviewService->getProductReviews($product->id, $filters, $perPage);
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+4
-3
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Base\PublicBaseController;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserCouponService;
|
||||
|
||||
/**
|
||||
@@ -27,16 +28,16 @@ class PublicCouponController extends PublicBaseController
|
||||
* 로그인 시 is_downloaded 정보가 추가됩니다.
|
||||
*
|
||||
* @param Request $request 요청 데이터
|
||||
* @param int $productId 상품 ID
|
||||
* @param Product $product 라우트 바인딩된 상품 (product_code 또는 id)
|
||||
* @return JsonResponse 쿠폰 목록
|
||||
*/
|
||||
public function downloadableCoupons(Request $request, int $productId): JsonResponse
|
||||
public function downloadableCoupons(Request $request, Product $product): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->logApiUsage('products.downloadable_coupons');
|
||||
|
||||
$userId = $request->user('sanctum')?->id;
|
||||
$coupons = $this->userCouponService->getProductDownloadableCoupons($productId, $userId);
|
||||
$coupons = $this->userCouponService->getProductDownloadableCoupons($product->id, $userId);
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
@@ -34,6 +34,7 @@ class ProductReviewResource extends BaseApiResource
|
||||
// 상품 정보
|
||||
'product' => $this->whenLoaded('product', fn () => [
|
||||
'id' => $this->product->id,
|
||||
'product_code' => $this->product->product_code,
|
||||
'name' => $this->product->getLocalizedName(),
|
||||
'thumbnail_url' => $this->product->getThumbnailUrl(),
|
||||
]),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Resources;
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use App\Http\Resources\BaseApiResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -162,6 +163,14 @@ class PublicProductResource extends BaseApiResource
|
||||
? $this->currentUserWishlist->isNotEmpty()
|
||||
: ProductWishlist::where('user_id', Auth::id())->where('product_id', $this->id)->exists()
|
||||
: false,
|
||||
|
||||
// 권한 (유저 상세 화면에서 관리자 상품 수정 화면 진입 게이트 전용)
|
||||
// can_update: 상품 수정 권한(sirsoft-ecommerce.products.update, Admin 타입) 보유자만 true.
|
||||
// 식별자는 DB 시더 권한 기준(products 복수형) — 라우트 정의의 단수 표기가 아닌 실제 등록 권한과 일치시킴.
|
||||
'abilities' => [
|
||||
'can_update' => Auth::check()
|
||||
&& Auth::user()->hasPermission('sirsoft-ecommerce.products.update', PermissionType::Admin),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ class SearchProductsListener implements HookListenerInterface
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'product_code' => $product->product_code,
|
||||
'name' => $name,
|
||||
'name_highlighted' => $this->highlightKeyword($name, $keyword),
|
||||
'short_description' => $shortDescription,
|
||||
@@ -219,7 +220,7 @@ class SearchProductsListener implements HookListenerInterface
|
||||
'multi_currency_selling_price' => $this->buildMultiCurrencyPrices($product->selling_price),
|
||||
'multi_currency_list_price' => $this->buildMultiCurrencyPrices($product->list_price),
|
||||
'labels' => $labels,
|
||||
'url' => '/shop/'.$product->id,
|
||||
'url' => '/shop/products/'.$product->product_code,
|
||||
'review_count' => (int) ($product->review_count ?? 0),
|
||||
'rating_avg' => $product->rating_avg !== null ? round((float) $product->rating_avg, 1) : 0.0,
|
||||
];
|
||||
|
||||
@@ -67,7 +67,7 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
// FULLTEXT 매칭 ID (Scout 엔진 순수 검색 — queryCallback 없음)
|
||||
$ftIds = Product::search($keyword)->keys()->all();
|
||||
|
||||
// 보조필드(product_code/sku/barcode) LIKE 매칭 ID (all 일 때만)
|
||||
// 보조필드(id/product_code/sku/barcode) LIKE 매칭 ID (all 일 때만)
|
||||
$auxIds = [];
|
||||
if ($field === 'all') {
|
||||
$auxIds = $this->model->newQuery()
|
||||
@@ -75,6 +75,11 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
$q->where('product_code', 'like', "%{$keyword}%")
|
||||
->orWhere('sku', 'like', "%{$keyword}%")
|
||||
->orWhere('barcode', 'like', "%{$keyword}%");
|
||||
|
||||
// 숫자 키워드는 상품 ID 정확 매칭도 포함 (예: "99" → id=99)
|
||||
if (ctype_digit($keyword)) {
|
||||
$q->orWhere('id', (int) $keyword);
|
||||
}
|
||||
})
|
||||
->pluck('id')
|
||||
->all();
|
||||
@@ -499,23 +504,49 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
{
|
||||
$page = (int) floor($offset / $limit) + 1;
|
||||
|
||||
$results = Product::search($keyword)
|
||||
->query(function ($query) use ($categoryId, $orderBy, $direction) {
|
||||
$query->where('display_status', ProductDisplayStatus::VISIBLE->value);
|
||||
// FULLTEXT(name/description) 매칭 + 보조필드(product_code/id) 매칭의 합집합.
|
||||
// 통합검색도 관리자 검색과 동일하게 상품코드/ID 로 상품을 찾을 수 있어야 한다.
|
||||
$matchedIds = $this->resolveKeywordMatchedProductIds($keyword);
|
||||
|
||||
$query->with(['images', 'primaryCategory', 'brand', 'activeLabelAssignments.label'])
|
||||
->withCount('visibleReviews as review_count')
|
||||
->withAvg('visibleReviews as rating_avg', 'rating');
|
||||
$paginator = $this->model->newQuery()
|
||||
->whereIn('id', $matchedIds ?: [0])
|
||||
->where('display_status', ProductDisplayStatus::VISIBLE->value)
|
||||
->with(['images', 'primaryCategory', 'brand', 'activeLabelAssignments.label'])
|
||||
->withCount('visibleReviews as review_count')
|
||||
->withAvg('visibleReviews as rating_avg', 'rating')
|
||||
->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId)))
|
||||
->orderBy($orderBy, $direction)
|
||||
->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$query->whereHas('categories', fn ($q) => $q->where('ecommerce_categories.id', $categoryId));
|
||||
return ['total' => $paginator->total(), 'items' => $paginator->getCollection()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 키워드로 매칭되는 상품 ID 집합을 반환합니다.
|
||||
*
|
||||
* FULLTEXT(name/description) Scout 매칭과 보조필드(product_code / 숫자 ID) 매칭의
|
||||
* 합집합. 통합검색의 결과 조회와 total 계산이 동일한 조건을 쓰도록 단일 경로로 산출한다.
|
||||
*
|
||||
* @param string $keyword 검색 키워드
|
||||
* @return array<int> 매칭된 상품 ID 배열
|
||||
*/
|
||||
private function resolveKeywordMatchedProductIds(string $keyword): array
|
||||
{
|
||||
$ftIds = Product::search($keyword)->keys()->all();
|
||||
|
||||
$auxIds = $this->model->newQuery()
|
||||
->where(function ($q) use ($keyword) {
|
||||
$q->where('product_code', 'like', "%{$keyword}%");
|
||||
|
||||
// 숫자 키워드는 상품 ID 정확 매칭도 포함
|
||||
if (ctype_digit($keyword)) {
|
||||
$q->orWhere('id', (int) $keyword);
|
||||
}
|
||||
|
||||
$query->orderBy($orderBy, $direction);
|
||||
})
|
||||
->paginate($limit, 'page', $page);
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
return ['total' => $results->total(), 'items' => $results->getCollection()];
|
||||
return array_values(array_unique([...$ftIds, ...$auxIds]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -523,16 +554,14 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
*/
|
||||
public function countByKeyword(string $keyword, ?int $categoryId = null): int
|
||||
{
|
||||
return Product::search($keyword)
|
||||
->query(function ($query) use ($categoryId) {
|
||||
$query->where('display_status', ProductDisplayStatus::VISIBLE->value);
|
||||
// searchByKeyword 와 동일한 매칭 조건(FULLTEXT ∪ 보조필드)으로 total 산출.
|
||||
$matchedIds = $this->resolveKeywordMatchedProductIds($keyword);
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$query->whereHas('categories', fn ($q) => $q->where('ecommerce_categories.id', $categoryId));
|
||||
}
|
||||
})
|
||||
->paginate(1)
|
||||
->total();
|
||||
return $this->model->newQuery()
|
||||
->whereIn('id', $matchedIds ?: [0])
|
||||
->where('display_status', ProductDisplayStatus::VISIBLE->value)
|
||||
->when($categoryId !== null, fn ($q) => $q->whereHas('categories', fn ($c) => $c->where('ecommerce_categories.id', $categoryId)))
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -316,9 +316,10 @@ class ProductInquiryService
|
||||
'product_id' => $inquiry->product_id,
|
||||
'product' => $inquiry->product ? [
|
||||
'id' => $inquiry->product->id,
|
||||
'product_code' => $inquiry->product->product_code,
|
||||
'name' => $inquiry->product->getLocalizedName(),
|
||||
'thumbnail_url' => $inquiry->product->getThumbnailUrl(),
|
||||
'url' => '/' . ltrim($this->settingsService->getSetting('basic_info.route_path', 'shop'), '/') . '/products/' . $inquiry->product->id,
|
||||
'url' => '/' . ltrim($this->settingsService->getSetting('basic_info.route_path', 'shop'), '/') . '/products/' . $inquiry->product->product_code,
|
||||
] : null,
|
||||
'product_name' => $this->localizeProductName($inquiry->product_name_snapshot),
|
||||
'is_answered' => $inquiry->is_answered,
|
||||
|
||||
@@ -95,29 +95,31 @@ Route::prefix('products')->middleware(['optional.sanctum', ResolveShippingCountr
|
||||
Route::get('/recent', [PublicProductController::class, 'recent'])
|
||||
->name('products.recent');
|
||||
|
||||
Route::get('/{id}', [PublicProductController::class, 'show'])
|
||||
->where('id', '[0-9]+')
|
||||
// 상품 상세 — product_code(16자 영숫자) 기준. 숫자 ID 도 하위호환 허용
|
||||
// (Product::resolveRouteBinding 이 code/id 자동 해석).
|
||||
Route::get('/{product}', [PublicProductController::class, 'show'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
->name('products.show');
|
||||
|
||||
Route::get('/{productId}/downloadable-coupons', [PublicCouponController::class, 'downloadableCoupons'])
|
||||
->where('productId', '[0-9]+')
|
||||
Route::get('/{product}/downloadable-coupons', [PublicCouponController::class, 'downloadableCoupons'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
->name('products.downloadable-coupons');
|
||||
|
||||
// 상품 공개 리뷰 목록 및 별점 통계 조회
|
||||
Route::get('/{productId}/reviews', [PublicProductReviewController::class, 'index'])
|
||||
->whereNumber('productId')
|
||||
Route::get('/{product}/reviews', [PublicProductReviewController::class, 'index'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
->name('products.reviews.index');
|
||||
|
||||
// 상품 1:1 문의 목록 조회 (비회원 포함)
|
||||
// GET /api/modules/sirsoft-ecommerce/products/{productId}/inquiries
|
||||
Route::get('/{productId}/inquiries', [PublicProductInquiryController::class, 'index'])
|
||||
->whereNumber('productId')
|
||||
// GET /api/modules/sirsoft-ecommerce/products/{product}/inquiries
|
||||
Route::get('/{product}/inquiries', [PublicProductInquiryController::class, 'index'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
->name('products.inquiries.index');
|
||||
|
||||
// 상품 1:1 문의 작성 (회원 전용)
|
||||
// POST /api/modules/sirsoft-ecommerce/products/{productId}/inquiries
|
||||
Route::post('/{productId}/inquiries', [PublicProductInquiryController::class, 'store'])
|
||||
->whereNumber('productId')
|
||||
// POST /api/modules/sirsoft-ecommerce/products/{product}/inquiries
|
||||
Route::post('/{product}/inquiries', [PublicProductInquiryController::class, 'store'])
|
||||
->where('product', '[0-9A-Za-z]+')
|
||||
->middleware('auth:sanctum')
|
||||
->name('products.inquiries.store');
|
||||
});
|
||||
|
||||
+34
@@ -72,6 +72,40 @@ class ProductControllerSearchTest extends ModuleTestCase
|
||||
$this->assertEquals($product->id, $data[0]['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* all 검색에서 숫자 키워드로 상품 ID 정확 매칭이 잡히는지 확인
|
||||
*/
|
||||
public function test_search_all_matches_by_product_id(): void
|
||||
{
|
||||
$user = $this->createAdminUser(['sirsoft-ecommerce.products.read']);
|
||||
$target = Product::factory()->create();
|
||||
Product::factory()->create(); // 노이즈
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/admin/products?search_field=all&search_keyword='.$target->id);
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json('data.data');
|
||||
$ids = array_column($data, 'id');
|
||||
$this->assertContains($target->id, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* all 검색에서 상품코드로 매칭이 잡히는지 확인 (id 매칭 추가 후 회귀 방지)
|
||||
*/
|
||||
public function test_search_all_matches_by_product_code(): void
|
||||
{
|
||||
$user = $this->createAdminUser(['sirsoft-ecommerce.products.read']);
|
||||
$product = Product::factory()->create(['product_code' => 'ALLCODE1234ABCD']);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/admin/products?search_field=all&search_keyword=ALLCODE1234ABCD');
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data.data'), 'id');
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* code 검색 필드(잘못된 값)가 거부되는지 확인
|
||||
*/
|
||||
|
||||
+85
@@ -475,6 +475,25 @@ class PublicProductControllerTest extends ModuleTestCase
|
||||
$response->assertJsonPath('data.id', $product->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 상세를 product_code(상품코드)로 조회할 수 있다 (#450 라우팅 code 전환)
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_product_by_product_code(): void
|
||||
{
|
||||
// Given: product_code 를 가진 상품
|
||||
$product = Product::factory()->onSale()->create();
|
||||
$this->assertNotEmpty($product->product_code);
|
||||
|
||||
// When: 숫자 id 가 아닌 product_code 로 상세 조회
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->product_code);
|
||||
|
||||
// Then: 동일 상품이 반환되고 응답에 product_code 노출
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.id', $product->id);
|
||||
$response->assertJsonPath('data.product_code', $product->product_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 상세 조회 시 PublicProductResource 필드 구조 검증 테스트
|
||||
*/
|
||||
@@ -1041,4 +1060,70 @@ class PublicProductControllerTest extends ModuleTestCase
|
||||
$this->assertEquals(0, $data['review_count']);
|
||||
$this->assertEquals(0.0, $data['rating_avg']);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// abilities.can_update 게이트 테스트 (#450)
|
||||
// 유저 상품 상세에서 관리자 상품 수정 화면 진입 크로스링크 노출 여부
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 상품 수정 권한(sirsoft-ecommerce.products.update) 보유자는 abilities.can_update = true
|
||||
*
|
||||
* @scenario link=user_product_to_admin_edit, permitted=true
|
||||
* @effects product_edit_gate_true_shows_link
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_can_update_true_for_product_update_permission_holder(): void
|
||||
{
|
||||
// Given: visible 상품 + 상품 수정 권한 보유 관리자
|
||||
$product = Product::factory()->onSale()->create();
|
||||
$admin = $this->createAdminUser(['sirsoft-ecommerce.products.update']);
|
||||
|
||||
// When: 권한 보유자가 유저 상품 상세 조회
|
||||
$response = $this->actingAs($admin, 'sanctum')
|
||||
->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->id);
|
||||
|
||||
// Then: abilities.can_update = true
|
||||
$response->assertStatus(200);
|
||||
$this->assertTrue($response->json('data.abilities.can_update'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 수정 권한 미보유 일반 사용자는 abilities.can_update = false
|
||||
*
|
||||
* @scenario link=user_product_to_admin_edit, permitted=false
|
||||
* @effects product_edit_gate_false_hides_link
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_can_update_false_for_regular_user(): void
|
||||
{
|
||||
// Given
|
||||
$product = Product::factory()->onSale()->create();
|
||||
$user = $this->createUser();
|
||||
|
||||
// When
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->id);
|
||||
|
||||
// Then
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($response->json('data.abilities.can_update'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비로그인(게스트)은 abilities.can_update = false
|
||||
*/
|
||||
#[Test]
|
||||
public function test_show_returns_can_update_false_for_guest(): void
|
||||
{
|
||||
// Given
|
||||
$product = Product::factory()->onSale()->create();
|
||||
|
||||
// When: 비로그인 조회
|
||||
$response = $this->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->id);
|
||||
|
||||
// Then
|
||||
$response->assertStatus(200);
|
||||
$this->assertFalse($response->json('data.abilities.can_update'));
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -75,6 +75,46 @@ class UserProductInquiryControllerTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// index() — 문의 목록 (마이페이지)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 마이페이지 문의 목록의 상품 링크가 상품 id 가 아닌 product_code 로 나간다 (#450).
|
||||
*
|
||||
* 회귀: getUserInquiries 의 product.url 이 id 로 하드코딩되어 있어 클릭 시
|
||||
* /shop/products/{id} 로 이동하던 버그. product_code 기준으로 수정.
|
||||
*/
|
||||
#[Test]
|
||||
public function 문의_목록의_상품_링크는_product_code_로_나간다(): void
|
||||
{
|
||||
// Given: product_code 를 가진 상품에 대한 본인 문의 (setUp 의 $this->inquiry)
|
||||
$this->assertNotEmpty($this->product->product_code);
|
||||
|
||||
// When: 마이페이지 문의 목록 조회
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/modules/sirsoft-ecommerce/user/inquiries');
|
||||
|
||||
// Then: 응답의 product.url 과 product.product_code 가 상품코드 기준
|
||||
// (getUserInquiries 응답 구조는 data.items[] + data.meta)
|
||||
$response->assertOk();
|
||||
$item = collect($response->json('data.items'))
|
||||
->firstWhere('id', $this->inquiry->id);
|
||||
|
||||
$this->assertNotNull($item, '본인 문의가 목록에 포함되어야 합니다.');
|
||||
$this->assertSame($this->product->product_code, $item['product']['product_code']);
|
||||
$this->assertStringContainsString(
|
||||
'/products/'.$this->product->product_code,
|
||||
$item['product']['url'],
|
||||
'상품 링크는 product_code 기준이어야 합니다.'
|
||||
);
|
||||
$this->assertStringNotContainsString(
|
||||
'/products/'.$this->product->id,
|
||||
$item['product']['url'],
|
||||
'상품 링크에 상품 id 가 노출되면 안 됩니다.'
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// update() — 문의 수정
|
||||
// ========================================
|
||||
|
||||
+48
@@ -279,4 +279,52 @@ class ProductSearchIntegrationTest extends ModuleTestCase
|
||||
$this->assertContains('suspended', $statuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합검색에서 상품코드(product_code)로 상품이 검색되는지 확인
|
||||
*
|
||||
* FULLTEXT(name/description) 에 없는 상품코드도 보조필드 매칭으로 잡혀야 한다.
|
||||
*/
|
||||
public function test_search_matches_product_by_product_code(): void
|
||||
{
|
||||
// Arrange — name 에는 없는 고유 상품코드
|
||||
$product = Product::factory()->create([
|
||||
'name' => ['ko' => '상품코드검색 대상', 'en' => 'code search target'],
|
||||
'product_code' => 'UNIQCODE9988XYZ0',
|
||||
'display_status' => ProductDisplayStatus::VISIBLE,
|
||||
]);
|
||||
$this->flushProductFulltextIndex();
|
||||
|
||||
// Act — 상품코드로 통합검색
|
||||
$response = $this->getJson('/api/search?q=UNIQCODE9988XYZ0&type=products');
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
$this->assertGreaterThanOrEqual(1, $data['products_count'] ?? 0);
|
||||
$ids = collect($data['products'])->pluck('id')->toArray();
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합검색에서 상품 ID(숫자 키워드)로 상품이 검색되는지 확인
|
||||
*/
|
||||
public function test_search_matches_product_by_id(): void
|
||||
{
|
||||
// Arrange — 다른 상품과 구분되는 이름의 대상 상품
|
||||
$product = Product::factory()->create([
|
||||
'name' => ['ko' => '아이디검색 대상', 'en' => 'id search target'],
|
||||
'display_status' => ProductDisplayStatus::VISIBLE,
|
||||
]);
|
||||
$this->flushProductFulltextIndex();
|
||||
|
||||
// Act — 상품 id 숫자로 통합검색
|
||||
$response = $this->getJson('/api/search?q='.$product->id.'&type=products');
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
$ids = collect($data['products'] ?? [])->pluck('id')->toArray();
|
||||
$this->assertContains($product->id, $ids);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,10 +6,18 @@
|
||||
|
||||
## [1.0.1] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- 관리자 사이드바 하단에 "사이트 보기" 링크를 추가했습니다 — 클릭하면 사용자 사이트 홈이 새 탭으로 열립니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 첨부파일을 허용 개수보다 많이 선택했을 때의 안내 문구를 "이미지는 최대 N장까지…"에서 "최대 N개까지 첨부할 수 있습니다"로 바꿔, 이미지뿐 아니라 문서 등 일반 파일을 올리는 화면에서도 자연스럽게 표시되도록 했습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 모바일에서 관리자 메뉴를 열면 메뉴 상단 일부가 상단 헤더에 가려 잘려 보이던 문제를 수정했습니다 — 이제 메뉴가 헤더 아래에서 시작해 첫 항목까지 온전히 보입니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"sidebar": {
|
||||
"no_accessible_menus": "No accessible menus"
|
||||
"no_accessible_menus": "No accessible menus",
|
||||
"view_site": "View site"
|
||||
},
|
||||
"profile_settings": "Profile Settings",
|
||||
"logout": "Logout",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"sidebar": {
|
||||
"no_accessible_menus": "접근 가능한 메뉴가 없습니다"
|
||||
"no_accessible_menus": "접근 가능한 메뉴가 없습니다",
|
||||
"view_site": "사이트 보기"
|
||||
},
|
||||
"profile_settings": "프로필 설정",
|
||||
"logout": "로그아웃",
|
||||
|
||||
@@ -292,6 +292,46 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sidebar_footer",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "sidebar_footer"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "sidebar_view_site_link",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:admin.sidebar.view_site",
|
||||
"className": "sidebar_view_site_link"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "I",
|
||||
"props": {
|
||||
"className": "sidebar_view_site_icon fas fa-globe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sidebar_view_site_label",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "sidebar_view_site_label"
|
||||
},
|
||||
"text": "$t:admin.sidebar.view_site"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @file sidebar-view-site-link.test.tsx
|
||||
* @description 관리자 사이드바 하단 "사이트 보기" 링크 + 모바일 드로어 상단 잘림 수정 회귀 테스트 (이슈 #450)
|
||||
*
|
||||
* 검증 항목:
|
||||
* 1. _admin_base.json 에 sidebar_footer > "사이트 보기" 링크 노드 존재
|
||||
* 2. 링크는 A 태그, href="/", target="_blank", rel="noopener noreferrer"
|
||||
* 3. 링크는 스크롤 메뉴(admin_sidebar_menu) 밖의 형제로 배치 (하단 고정)
|
||||
* 4. 모바일 드로어(.left_sidebar_area_portable) 가 inset-y-0(top:0) 대신 헤더 아래(top:4rem)에서 시작
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import adminBase from '../../../layouts/_admin_base.json';
|
||||
|
||||
interface LayoutNode {
|
||||
id?: string;
|
||||
name?: string;
|
||||
props?: Record<string, unknown>;
|
||||
children?: LayoutNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function findById(node: LayoutNode | LayoutNode[] | undefined, id: string): LayoutNode | null {
|
||||
if (!node) return null;
|
||||
const nodes = Array.isArray(node) ? node : [node];
|
||||
for (const n of nodes) {
|
||||
if (n && typeof n === 'object') {
|
||||
if (n.id === id) return n;
|
||||
for (const key of Object.keys(n)) {
|
||||
const child = (n as Record<string, unknown>)[key];
|
||||
if (Array.isArray(child) || (child && typeof child === 'object')) {
|
||||
const found = findById(child as LayoutNode | LayoutNode[], id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('이슈 #450 — 관리자 사이드바 "사이트 보기" 링크', () => {
|
||||
it('sidebar_footer 노드가 존재한다', () => {
|
||||
const footer = findById(adminBase as LayoutNode, 'sidebar_footer');
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer?.name).toBe('Div');
|
||||
});
|
||||
|
||||
it('사이트 보기 링크는 A 태그로 유저 홈(/)을 새 탭으로 연다', () => {
|
||||
const link = findById(adminBase as LayoutNode, 'sidebar_view_site_link');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.name).toBe('A');
|
||||
expect(link?.props?.href).toBe('/');
|
||||
expect(link?.props?.target).toBe('_blank');
|
||||
expect(link?.props?.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('노출 조건(if)이 없어 관리자 화면에서 항상 표시된다', () => {
|
||||
const link = findById(adminBase as LayoutNode, 'sidebar_view_site_link');
|
||||
expect(link?.if).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sidebar_footer 는 스크롤 메뉴(admin_sidebar_menu) 내부가 아닌 left_sidebar_area 의 직접 자식이다', () => {
|
||||
const sidebarArea = findById(adminBase as LayoutNode, 'left_sidebar_area');
|
||||
const childIds = (sidebarArea?.children ?? []).map((c) => c.id);
|
||||
expect(childIds).toContain('sidebar_footer');
|
||||
// 스크롤 메뉴 내부에 footer 가 중첩되지 않아야 함 (하단 고정 보장)
|
||||
const menu = findById(adminBase as LayoutNode, 'admin_sidebar_menu');
|
||||
const menuChildIds = (menu?.children ?? []).map((c) => c.id);
|
||||
expect(menuChildIds).not.toContain('sidebar_footer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('이슈 #450 — 모바일 드로어 상단 잘림 수정 (CSS)', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../styles/layout/_admin_base.css'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('.left_sidebar_area_portable 는 inset-y-0(top:0)로 헤더를 덮지 않는다', () => {
|
||||
const block = css.match(/\.left_sidebar_area_portable\s*\{[^}]*\}/s)?.[0] ?? '';
|
||||
expect(block).not.toContain('inset-y-0');
|
||||
});
|
||||
|
||||
it('.left_sidebar_area_portable 는 헤더 높이(4rem)만큼 내려 시작한다', () => {
|
||||
const block = css.match(/\.left_sidebar_area_portable\s*\{[^}]*\}/s)?.[0] ?? '';
|
||||
expect(block).toMatch(/top:\s*4rem/);
|
||||
expect(block).toMatch(/bottom:\s*0/);
|
||||
});
|
||||
|
||||
it('.sidebar_view_site_link 스타일이 정의되어 있다', () => {
|
||||
expect(css).toContain('.sidebar_view_site_link');
|
||||
expect(css).toContain('.sidebar_footer');
|
||||
});
|
||||
});
|
||||
@@ -54,9 +54,16 @@
|
||||
@apply w-0 overflow-hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* 모바일 드로어. 모바일 헤더(.mobile_header_portable, h-16=4rem, relative)가 일반 흐름
|
||||
* 최상단에 있으므로, 드로어를 inset-y-0(top:0)로 두면 헤더 영역을 덮어 첫 메뉴 항목이
|
||||
* 잘려 보인다(#450). top 을 헤더 높이(4rem)만큼 내려 드로어가 헤더 아래에서 시작하게 한다.
|
||||
*/
|
||||
.left_sidebar_area_portable {
|
||||
@apply fixed inset-y-0 left-0 z-50 w-64 transition-transform duration-300 flex flex-col bg-white;
|
||||
@apply fixed left-0 z-50 w-64 transition-transform duration-300 flex flex-col bg-white;
|
||||
@apply dark:bg-gray-800;
|
||||
top: 4rem;
|
||||
bottom: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@@ -106,6 +113,38 @@
|
||||
@apply dark:text-gray-600;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* 4-1. SIDEBAR FOOTER (사이트 보기 링크)
|
||||
*
|
||||
* 스크롤 메뉴(.admin_sidebar_menu) 밖의 형제로 사이드바 하단에 고정.
|
||||
* 관리자 → 유저 사이트 진입 링크. 데스크톱 접힘 시 아이콘만 표시.
|
||||
* ======================================== */
|
||||
|
||||
.sidebar_footer {
|
||||
@apply flex-shrink-0 border-t border-gray-100 p-3;
|
||||
@apply dark:border-gray-700;
|
||||
}
|
||||
|
||||
.sidebar_view_site_link {
|
||||
@apply flex items-center gap-2 h-9 px-3 rounded-lg no-underline;
|
||||
@apply text-sm font-medium text-slate-600 transition-colors;
|
||||
@apply hover:bg-slate-100 hover:text-slate-900;
|
||||
@apply dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-white;
|
||||
}
|
||||
|
||||
.sidebar_view_site_icon {
|
||||
@apply text-sm flex-shrink-0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 데스크톱 접힘 상태: 라벨을 숨기고 아이콘만 중앙 정렬.
|
||||
* (.left_sidebar_area_collapsed 는 w-0 overflow-hidden 이므로 접힘 시 자동으로
|
||||
* footer 도 함께 잘리지만, 접힘 애니메이션 중 라벨 줄바꿈 방지를 위해 truncate 처리)
|
||||
*/
|
||||
.sidebar_view_site_label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* 5. RIGHT CONTENT WRAPPER + AREA
|
||||
* ======================================== */
|
||||
|
||||
@@ -363,6 +363,8 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
### Added
|
||||
|
||||
- 관리자가 발행 전 페이지를 사용자 화면에서 미리 볼 때, 미발행 상태임을 알리는 안내 표시를 추가했습니다.
|
||||
- 사용자 게시판 화면(목록·상세)에 관리자 진입 버튼을 추가했습니다 — 게시판 관리 권한이 있는 사용자에게만 "관리자 게시물 조회"·"게시판 관리" 버튼이 보이며, 클릭하면 새 탭으로 열립니다.
|
||||
- 사용자 상품 상세 화면에 "관리자에서 수정" 버튼을 추가했습니다 — 상품 수정 권한이 있는 사용자에게만 보이며, 클릭하면 해당 상품의 관리자 수정 화면이 새 탭으로 열립니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 상품 상세 페이지 주소가 상품 번호 대신 상품코드 기준으로 바뀌었습니다 (상품 목록·최근 본 상품·검색·장바구니·주문내역 등 모든 상품 링크 포함). 기존 번호 기반 주소도 계속 동작합니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+17
-18
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
@@ -11,6 +11,8 @@ interface ProductLabel {
|
||||
interface ProductCardProps {
|
||||
product: {
|
||||
id: number;
|
||||
/** 상품코드 — 상세 페이지 링크 식별자 (product_code 기준 라우팅). 없으면 id 로 폴백 */
|
||||
product_code?: string;
|
||||
name?: string | Record<string, string>;
|
||||
name_localized?: string;
|
||||
thumbnail_url: string;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"loading_posts": "Loading posts...",
|
||||
"view_board": "View Board",
|
||||
"admin_links": {
|
||||
"posts": "Admin posts",
|
||||
"manage": "Board settings"
|
||||
},
|
||||
"no": "No.",
|
||||
"title": "Title",
|
||||
"author": "Author",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "Shop",
|
||||
"description": "Discover various products",
|
||||
"admin_edit": "Edit in admin",
|
||||
"shippability": {
|
||||
"not_shippable": "This item cannot be shipped to the selected country.",
|
||||
"not_shippable_short": "Not shippable",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"loading_posts": "게시글을 불러오는 중입니다...",
|
||||
"view_board": "게시판 보기",
|
||||
"admin_links": {
|
||||
"posts": "관리자 게시물 조회",
|
||||
"manage": "게시판 관리"
|
||||
},
|
||||
"no": "번호",
|
||||
"title": "제목",
|
||||
"author": "작성자",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": "쇼핑",
|
||||
"description": "다양한 상품을 만나보세요",
|
||||
"admin_edit": "관리자에서 수정",
|
||||
"shippability": {
|
||||
"not_shippable": "선택하신 배송국가로는 배송할 수 없는 상품입니다.",
|
||||
"not_shippable_short": "배송 불가",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "게시판 관리자 진입 크로스링크 (관리자 게시물 조회 + 게시판 관리). can_access_admin 권한 보유자에게만 노출, 새 탭으로 이동."
|
||||
},
|
||||
"comment": "관리자 진입 링크 묶음 - 유저 게시판 화면에서 관리자 화면으로 이동 (권한 게이트)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{posts?.data?.abilities?.can_access_admin}}",
|
||||
"props": {
|
||||
"className": "inline-flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/admin/board/{{route.slug}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:board.admin_links.posts",
|
||||
"className": "inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "hidden sm:inline"
|
||||
},
|
||||
"text": "$t:board.admin_links.posts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/admin/boards/{{route.slug}}/edit",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:board.admin_links.manage",
|
||||
"className": "inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "settings",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "hidden sm:inline"
|
||||
},
|
||||
"text": "$t:board.admin_links.manage"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,8 +38,22 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상단 글쓰기 버튼",
|
||||
"partial": "../../index/_write_button.json"
|
||||
"comment": "상단 액션 (관리자 진입 링크 + 글쓰기 버튼)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "관리자 진입 크로스링크 (권한 보유 시)",
|
||||
"partial": "../../index/_admin_links.json"
|
||||
},
|
||||
{
|
||||
"comment": "상단 글쓰기 버튼",
|
||||
"partial": "../../index/_write_button.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,6 +11,75 @@
|
||||
"data_sources": "post"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "관리자 진입 크로스링크 (게시판 관리 권한 보유 시) — 관리자 게시물 조회 + 게시판 관리, 새 탭",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{post?.data?.abilities?.can_access_admin}}",
|
||||
"props": {
|
||||
"className": "flex items-center justify-end gap-2 mb-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/admin/board/{{route.slug}}/post/{{post?.data?.id ?? route.id}}",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:board.admin_links.posts",
|
||||
"className": "inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "external-link",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "hidden sm:inline"
|
||||
},
|
||||
"text": "$t:board.admin_links.posts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "/admin/boards/{{route.slug}}/edit",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:board.admin_links.manage",
|
||||
"className": "inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "settings",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "hidden sm:inline"
|
||||
},
|
||||
"text": "$t:board.admin_links.manage"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "게시글 본문 카드",
|
||||
"type": "basic",
|
||||
|
||||
@@ -38,8 +38,22 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상단 글쓰기 버튼",
|
||||
"partial": "../../index/_write_button.json"
|
||||
"comment": "상단 액션 (관리자 진입 링크 + 글쓰기 버튼)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "관리자 진입 크로스링크 (권한 보유 시)",
|
||||
"partial": "../../index/_admin_links.json"
|
||||
},
|
||||
{
|
||||
"comment": "상단 글쓰기 버튼",
|
||||
"partial": "../../index/_write_button.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -38,8 +38,22 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "상단 글쓰기 버튼",
|
||||
"partial": "../../index/_write_button.json"
|
||||
"comment": "상단 액션 (관리자 진입 링크 + 글쓰기 버튼)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "관리자 진입 크로스링크 (권한 보유 시)",
|
||||
"partial": "../../index/_admin_links.json"
|
||||
},
|
||||
{
|
||||
"comment": "상단 글쓰기 버튼",
|
||||
"partial": "../../index/_write_button.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "{{_global.shopBase}}/products/{{item.product_id}}" }
|
||||
"params": { "path": "{{_global.shopBase}}/products/{{item.product_snapshot?.product_code ?? item.product_id}}" }
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -223,7 +223,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": { "path": "{{_global.shopBase}}/products/{{item.product_id}}" }
|
||||
"params": { "path": "{{_global.shopBase}}/products/{{item.product_snapshot?.product_code ?? item.product_id}}" }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": { "className": "flex w-full text-left items-center gap-4 p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer" },
|
||||
"actions": [{ "type": "click", "handler": "navigate", "params": { "path": "{{_global.shopBase}}/products/{{product.id}}" } }],
|
||||
"actions": [{ "type": "click", "handler": "navigate", "params": { "path": "{{_global.shopBase}}/products/{{product.product_code ?? product.id}}" } }],
|
||||
"children": [
|
||||
{
|
||||
"comment": "썸네일",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"comment": "상품 이미지 (새 창으로 열기)",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product_id}}", "target": "_blank", "className": "flex-shrink-0" },
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product?.product_code ?? item.product_id}}", "target": "_blank", "className": "flex-shrink-0" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -57,7 +57,7 @@
|
||||
"comment": "상품명 (새 창으로 열기)",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product_id}}", "target": "_blank", "className": "block w-full text-left no-underline" },
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product?.product_code ?? item.product_id}}", "target": "_blank", "className": "block w-full text-left no-underline" },
|
||||
"children": [
|
||||
{ "type": "basic", "name": "H3", "props": { "className": "font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 truncate" }, "text": "{{item.product.name}}" }
|
||||
]
|
||||
@@ -304,7 +304,7 @@
|
||||
{
|
||||
"comment": "상품 옵션 정보 조회 후 모달 열기",
|
||||
"handler": "apiCall",
|
||||
"target": "/api/modules/sirsoft-ecommerce/products/{{item.product_id}}",
|
||||
"target": "/api/modules/sirsoft-ecommerce/products/{{item.product?.product_code ?? item.product_id}}",
|
||||
"auth_mode": "optional",
|
||||
"onSuccess": [
|
||||
{
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"comment": "상품 이미지 (새 창으로 열기)",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product_id}}", "target": "_blank", "className": "flex-shrink-0" },
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product?.product_code ?? item.product_id}}", "target": "_blank", "className": "flex-shrink-0" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -97,7 +97,7 @@
|
||||
"comment": "상품명 (새 창으로 열기)",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product_id}}", "target": "_blank", "className": "block no-underline" },
|
||||
"props": { "href": "{{_global.shopBase}}/products/{{item.product?.product_code ?? item.product_id}}", "target": "_blank", "className": "block no-underline" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"meta": {
|
||||
"is_partial": true,
|
||||
"description": "유저 상품 상세에서 관리자 상품 수정 화면으로 이동하는 링크. 상품 수정 권한(can_update) 보유 시에만 노출, 새 탭."
|
||||
},
|
||||
"comment": "관리자 상품 수정 링크 (권한 게이트)",
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"if": "{{product.data?.abilities?.can_update}}",
|
||||
"props": {
|
||||
"href": "/admin/ecommerce/products/{{product.data?.product_code ?? route.product_code}}/edit",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"title": "$t:shop.admin_edit",
|
||||
"className": "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors no-underline flex-shrink-0"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "pencil",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "hidden sm:inline"
|
||||
},
|
||||
"text": "$t:shop.admin_edit"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -69,6 +69,18 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "우측 액션 그룹 (관리자 수정 링크 + 찜하기)",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2 flex-shrink-0"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"comment": "관리자 상품 수정 링크 (권한 게이트, 새 탭)",
|
||||
"partial": "./_admin_edit_link.json"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
@@ -169,6 +181,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
"id": "product",
|
||||
"label_key": "$t:editor.data_source.product",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.id}}",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.product_code}}",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_mode": "optional",
|
||||
@@ -134,7 +134,7 @@
|
||||
"id": "reviews",
|
||||
"label_key": "$t:editor.data_source.reviews",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.id}}/reviews",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.product_code}}/reviews",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_mode": "optional",
|
||||
@@ -170,7 +170,7 @@
|
||||
"id": "qna",
|
||||
"label_key": "$t:editor.data_source.qna",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.id}}/inquiries",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.product_code}}/inquiries",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_mode": "optional",
|
||||
@@ -207,7 +207,7 @@
|
||||
"id": "productDownloadableCoupons",
|
||||
"label_key": "$t:editor.data_source.productDownloadableCoupons",
|
||||
"type": "api",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.id}}/downloadable-coupons",
|
||||
"endpoint": "/api/modules/sirsoft-ecommerce/products/{{route.product_code}}/downloadable-coupons",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_mode": "optional",
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/products/:id",
|
||||
"path": "/{{_global.modules?.['sirsoft-ecommerce']?.basic_info?.no_route ? '' : (_global.modules?.['sirsoft-ecommerce']?.basic_info?.route_path ?? 'shop')}}/products/:product_code",
|
||||
"layout": "shop/show",
|
||||
"auth_required": false,
|
||||
"meta": {
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @file admin-cross-navigation.test.tsx
|
||||
* @description 유저 화면 → 관리자 화면 크로스링크 노출 게이트 회귀 테스트 (이슈 #450)
|
||||
*
|
||||
* 검증 항목:
|
||||
* 1. 게시판: can_access_admin = true → 관리자 진입 링크 2개(게시물 조회 + 게시판 관리) 렌더
|
||||
* 2. 게시판: can_access_admin = false → 관리자 진입 링크 미렌더
|
||||
* 3. 상품: can_update = true → 관리자 상품 수정 링크 렌더 + href 검증
|
||||
* 4. 상품: can_update = false → 관리자 상품 수정 링크 미렌더
|
||||
* 5. 게이트는 실제 파티셜 JSON(_admin_links.json / _admin_edit_link.json)의 if 표현식과 일치
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createLayoutTest, screen } from '@/core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
|
||||
|
||||
// 실제 파티셜 JSON — 게이트 표현식/href 회귀 고정
|
||||
import adminLinks from '../../../layouts/partials/board/index/_admin_links.json';
|
||||
import adminEditLink from '../../../layouts/partials/shop/detail/_admin_edit_link.json';
|
||||
|
||||
// ============================================================
|
||||
// 테스트용 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
const TestDiv: React.FC<{ className?: string; children?: React.ReactNode; 'data-testid'?: string }> = ({
|
||||
className,
|
||||
children,
|
||||
'data-testid': testId,
|
||||
}) => (
|
||||
<div className={className} data-testid={testId}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const TestA: React.FC<{
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}> = ({ href, target, rel, title, className, children, 'data-testid': testId }) => (
|
||||
<a href={href} target={target} rel={rel} title={title} className={className} data-testid={testId}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({
|
||||
className,
|
||||
children,
|
||||
text,
|
||||
}) => <span className={className}>{children || text}</span>;
|
||||
|
||||
const TestIcon: React.FC<{ name?: string }> = ({ name }) => <i data-icon={name} />;
|
||||
|
||||
function setupTestRegistry(): ComponentRegistry {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
const Fragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
|
||||
(registry as unknown as { registry: Record<string, unknown> }).registry = {
|
||||
Fragment: { component: Fragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
A: { component: TestA, metadata: { name: 'A', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 게시판 admin-links 게이트 (can_access_admin)
|
||||
// ============================================================
|
||||
|
||||
const boardAdminLinksLayout = {
|
||||
version: '1.0.0',
|
||||
layout_name: 'board_admin_links_test',
|
||||
data_sources: [
|
||||
{
|
||||
id: 'posts',
|
||||
type: 'api',
|
||||
endpoint: '/api/modules/sirsoft-board/boards/test/posts',
|
||||
method: 'GET',
|
||||
auto_fetch: true,
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'A',
|
||||
if: '{{posts?.data?.abilities?.can_access_admin}}',
|
||||
props: {
|
||||
'data-testid': 'board-admin-posts-link',
|
||||
href: '/admin/board/{{route.slug}}',
|
||||
target: '_blank',
|
||||
},
|
||||
children: [{ type: 'basic', name: 'Span', text: 'Admin posts' }],
|
||||
},
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'A',
|
||||
if: '{{posts?.data?.abilities?.can_access_admin}}',
|
||||
props: {
|
||||
'data-testid': 'board-admin-manage-link',
|
||||
href: '/admin/boards/{{route.slug}}/edit',
|
||||
target: '_blank',
|
||||
},
|
||||
children: [{ type: 'basic', name: 'Span', text: 'Board settings' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 상품 admin edit 게이트 (can_update)
|
||||
// ============================================================
|
||||
|
||||
const productAdminEditLayout = {
|
||||
version: '1.0.0',
|
||||
layout_name: 'product_admin_edit_test',
|
||||
data_sources: [
|
||||
{
|
||||
id: 'product',
|
||||
type: 'api',
|
||||
endpoint: '/api/modules/sirsoft-ecommerce/products/1',
|
||||
method: 'GET',
|
||||
auto_fetch: true,
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'A',
|
||||
if: '{{product.data?.abilities?.can_update}}',
|
||||
props: {
|
||||
'data-testid': 'product-admin-edit-link',
|
||||
href: '/admin/ecommerce/products/{{product.data?.product_code ?? route.product_code}}/edit',
|
||||
target: '_blank',
|
||||
},
|
||||
children: [{ type: 'basic', name: 'Span', text: 'Edit in admin' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 테스트
|
||||
// ============================================================
|
||||
|
||||
describe('이슈 #450 — 유저→관리자 크로스링크 게이트', () => {
|
||||
beforeEach(() => {
|
||||
setupTestRegistry();
|
||||
});
|
||||
|
||||
describe('게시판 관리자 진입 링크 (can_access_admin)', () => {
|
||||
it('can_access_admin = true → 게시물 조회 + 게시판 관리 링크 2개 렌더', async () => {
|
||||
const testUtils = createLayoutTest(boardAdminLinksLayout, { routeParams: { slug: 'free' } });
|
||||
testUtils.mockApi('posts', {
|
||||
response: { data: { abilities: { can_access_admin: true } } },
|
||||
});
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByTestId('board-admin-posts-link')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('board-admin-manage-link')).toBeInTheDocument();
|
||||
// "게시판 관리" 는 해당 게시판의 개별 설정 화면(/admin/boards/:slug/edit)으로 이동
|
||||
expect(screen.getByTestId('board-admin-manage-link').getAttribute('href')).toBe('/admin/boards/free/edit');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('can_access_admin = false → 관리자 진입 링크 미렌더', async () => {
|
||||
const testUtils = createLayoutTest(boardAdminLinksLayout);
|
||||
testUtils.mockApi('posts', {
|
||||
response: { data: { abilities: { can_access_admin: false } } },
|
||||
});
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.queryByTestId('board-admin-posts-link')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('board-admin-manage-link')).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('상품 관리자 수정 링크 (can_update)', () => {
|
||||
it('can_update = true → 관리자 상품 수정 링크 렌더', async () => {
|
||||
const testUtils = createLayoutTest(productAdminEditLayout);
|
||||
testUtils.mockApi('product', {
|
||||
response: { data: { id: 42, product_code: 'KOWZBDA045RJQHDJ', abilities: { can_update: true } } },
|
||||
});
|
||||
await testUtils.render();
|
||||
|
||||
const link = screen.getByTestId('product-admin-edit-link');
|
||||
expect(link).toBeInTheDocument();
|
||||
// 관리자 편집 링크는 product_code 기준
|
||||
expect(link.getAttribute('href')).toBe('/admin/ecommerce/products/KOWZBDA045RJQHDJ/edit');
|
||||
expect(link.getAttribute('target')).toBe('_blank');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('can_update = false → 관리자 상품 수정 링크 미렌더', async () => {
|
||||
const testUtils = createLayoutTest(productAdminEditLayout);
|
||||
testUtils.mockApi('product', {
|
||||
response: { data: { id: 42, product_code: 'KOWZBDA045RJQHDJ', abilities: { can_update: false } } },
|
||||
});
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.queryByTestId('product-admin-edit-link')).not.toBeInTheDocument();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 실제 파티셜 JSON 게이트/속성 회귀 고정 (구조 분석)
|
||||
// ============================================================
|
||||
describe('파티셜 JSON 회귀 고정', () => {
|
||||
it('_admin_links.json 은 can_access_admin 게이트를 사용한다', () => {
|
||||
expect((adminLinks as { if?: string }).if).toBe('{{posts?.data?.abilities?.can_access_admin}}');
|
||||
});
|
||||
|
||||
it('_admin_links.json 은 관리자 게시물 조회 + 게시판 관리 링크를 새 탭으로 연다', () => {
|
||||
const dump = JSON.stringify(adminLinks);
|
||||
expect(dump).toContain('/admin/board/{{route.slug}}');
|
||||
// "게시판 관리" 는 개별 게시판 설정 화면(:slug/edit)으로 이동
|
||||
expect(dump).toContain('/admin/boards/{{route.slug}}/edit');
|
||||
expect(dump).toContain('"target":"_blank"');
|
||||
});
|
||||
|
||||
it('_admin_edit_link.json 은 can_update 게이트 + 새 탭 + 상품 수정 경로를 사용한다', () => {
|
||||
expect((adminEditLink as { if?: string }).if).toBe('{{product.data?.abilities?.can_update}}');
|
||||
const dump = JSON.stringify(adminEditLink);
|
||||
// 상품 관리자 편집 링크는 product_code 기준
|
||||
expect(dump).toContain('/admin/ecommerce/products/{{product.data?.product_code ?? route.product_code}}/edit');
|
||||
expect(dump).toContain('"target":"_blank"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,8 @@ interface ProductLabel {
|
||||
interface ProductCardProps {
|
||||
product: {
|
||||
id: number;
|
||||
/** 상품코드 — 상세 페이지 링크 식별자 (product_code 기준 라우팅). 없으면 id 로 폴백 */
|
||||
product_code?: string;
|
||||
name?: string | Record<string, string>;
|
||||
name_localized?: string;
|
||||
thumbnail_url: string;
|
||||
@@ -218,7 +220,9 @@ const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onClick(product.id);
|
||||
} else {
|
||||
const base = shopBase === '/' ? '' : shopBase;
|
||||
navigate(`${base}/products/${product.id}`);
|
||||
// 상세 페이지는 product_code 기준 라우팅. code 미제공 시 id 로 폴백(하위호환).
|
||||
const identifier = product.product_code ?? product.id;
|
||||
navigate(`${base}/products/${identifier}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* E2E: 관리자 ↔ 유저 화면 상호 이동 링크 + 모바일 사이드바 드로어 상단 잘림 수정 (#450)
|
||||
*
|
||||
* @scenario admin_sidebar_view_site, admin_board_view_user_page, mobile_drawer_not_clipped
|
||||
* @effects new_tab_navigation, drawer_starts_below_header
|
||||
*
|
||||
* 검증:
|
||||
* 1. 관리자 사이드바 하단 "사이트 보기" 링크가 A[href="/"][target="_blank"] 로 마운트
|
||||
* 2. 관리자 게시물 조회 화면에 "유저 화면 보기" 링크가 새 탭으로 마운트
|
||||
* 3. 모바일 뷰포트에서 드로어를 열면 드로어 상단이 헤더(약 64px) 아래에서 시작 —
|
||||
* 첫 메뉴 항목이 헤더에 가려 잘리지 않는다 (버그 회귀 가드)
|
||||
*/
|
||||
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||
|
||||
/** admin 진입 공통 대기 */
|
||||
async function gotoAdmin(page: import('@playwright/test').Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.waitForFunction(
|
||||
() => !window.location.pathname.includes('/admin/login') || document.readyState === 'complete',
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
// @scenario link=admin_sidebar_to_user_site, permitted=na
|
||||
// @effects new_tab_navigation
|
||||
test('@smoke #450 - 관리자 사이드바 "사이트 보기" 링크가 새 탭 A 로 마운트된다', async ({ page }) => {
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await gotoAdmin(page, '/admin/dashboard');
|
||||
expect(page.url()).not.toMatch(/\/admin\/login/);
|
||||
|
||||
const link = page.locator('#sidebar_view_site_link');
|
||||
await expect(link).toBeAttached({ timeout: 10_000 });
|
||||
await expect(link).toHaveAttribute('href', '/');
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
// 사이드바 footer 는 스크롤 메뉴 밖의 형제이므로, 메뉴 영역과 별도로 마운트된다.
|
||||
await expect(page.locator('#sidebar_footer')).toBeAttached();
|
||||
});
|
||||
|
||||
// @scenario link=admin_board_to_user_board, permitted=na
|
||||
// @effects new_tab_navigation, board_admin_routes_by_slug
|
||||
test('@smoke #450 - 관리자 게시물 조회 화면에 "유저 화면 보기" 새 탭 링크가 마운트된다', async ({ page }) => {
|
||||
// 게시판 관리 권한으로 관리자 게시물 목록 진입
|
||||
const token = issueToken('sirsoft-board.notice.admin.posts.read', 'sirsoft-board.notice.admin.manage');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
await gotoAdmin(page, '/admin/board/notice');
|
||||
expect(page.url()).not.toMatch(/\/admin\/login/);
|
||||
|
||||
const link = page.locator('#view_user_page_link').first();
|
||||
// 게시판이 없거나 접근 불가 환경에서는 스킵 (권한/데이터 의존)
|
||||
if ((await link.count()) === 0) {
|
||||
test.skip(true, '게시판(notice) 미존재 또는 접근 불가 — 데이터 의존 스킵');
|
||||
}
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('href', /\/board\/notice/);
|
||||
});
|
||||
|
||||
// @scenario link=admin_product_to_user_detail, permitted=na
|
||||
// @effects new_tab_navigation, product_detail_routes_by_code
|
||||
test('@smoke #450 - 관리자 상품 수정 화면에 "유저 화면 보기" 새 탭 링크가 마운트된다', async ({ page }) => {
|
||||
const token = issueToken('sirsoft-ecommerce.product.read', 'sirsoft-ecommerce.product.update');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
// 임의 상품 편집 경로 진입 (상품 미존재 시 링크 미노출 → 데이터 의존 스킵)
|
||||
await gotoAdmin(page, '/admin/ecommerce/products/1/edit');
|
||||
expect(page.url()).not.toMatch(/\/admin\/login/);
|
||||
|
||||
const link = page.locator('#view_user_page_link').first();
|
||||
if ((await link.count()) === 0) {
|
||||
test.skip(true, '상품(id=1) 미존재 또는 접근 불가 — 데이터 의존 스킵');
|
||||
}
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('href', /\/products\/1$/);
|
||||
});
|
||||
|
||||
// @scenario link=admin_sidebar_to_user_site, permitted=na
|
||||
// @effects drawer_starts_below_header
|
||||
test('@smoke #450 - 모바일 드로어가 헤더 아래에서 시작해 첫 메뉴가 잘리지 않는다', async ({ page }) => {
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
// 모바일 뷰포트 (portable breakpoint 진입)
|
||||
await page.setViewportSize({ width: 390, height: 780 });
|
||||
await gotoAdmin(page, '/admin/dashboard');
|
||||
expect(page.url()).not.toMatch(/\/admin\/login/);
|
||||
|
||||
// 햄버거로 드로어 열기 (모바일 헤더의 토글). 토글 id 는 menu_toggle_btn 계열.
|
||||
const toggle = page.locator('#menu_toggle_btn, [data-testid="menu_toggle_btn"], .menu_toggle_btn').first();
|
||||
if ((await toggle.count()) > 0) {
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
const drawer = page.locator('#left_sidebar_area');
|
||||
await expect(drawer).toBeAttached({ timeout: 10_000 });
|
||||
|
||||
// 드로어의 상단 y 좌표가 헤더 높이(약 64px = h-16) 이상이어야 한다.
|
||||
// inset-y-0(top:0) 회귀 시 이 값이 0 이 되어 첫 메뉴가 헤더에 가려진다.
|
||||
const box = await drawer.boundingBox();
|
||||
if (box) {
|
||||
expect(box.y, '드로어가 top:0 으로 헤더를 덮음 (#450 회귀)').toBeGreaterThanOrEqual(48);
|
||||
}
|
||||
|
||||
// 첫 메뉴 항목의 top 이 헤더 하단(약 56~64px)보다 아래에 있어야 한다.
|
||||
const firstItem = page.locator('#admin_sidebar_menu .admin-sidebar-item').first();
|
||||
if ((await firstItem.count()) > 0) {
|
||||
const itemBox = await firstItem.boundingBox();
|
||||
if (itemBox) {
|
||||
expect(itemBox.y, '첫 메뉴 항목이 헤더에 가려 잘림 (#450 회귀)').toBeGreaterThanOrEqual(48);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
feature: admin_user_cross_navigation
|
||||
description: >
|
||||
관리자 ↔ 유저 화면 상호 이동 링크 (#450). 관리자 사이드바 "사이트 보기",
|
||||
관리자 게시물 조회 ↔ 유저 게시판, 유저 게시판 → 관리자(게시물 조회+게시판 관리),
|
||||
유저 상품 상세 ↔ 관리자 상품 수정. 유저측 관리자 진입 링크는 세부 권한 플래그로 게이트.
|
||||
모바일 사이드바 드로어 상단 잘림 수정 포함.
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/PostResourcePermissionTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/PublicProductControllerTest.php
|
||||
- tests/Playwright/specs/admin/cross-navigation-and-mobile-drawer.spec.ts
|
||||
|
||||
# 입력 axis
|
||||
# link : 크로스링크 종류 (출발→도착 surface)
|
||||
# permitted: 유저측 관리자 진입 권한 보유 여부 (관리자측/항상노출 링크는 n/a 로 컷)
|
||||
axes:
|
||||
link: [admin_sidebar_to_user_site, admin_board_to_user_board, user_board_to_admin, user_product_to_admin_edit, admin_product_to_user_detail]
|
||||
permitted: [true, false, na]
|
||||
|
||||
# 의미 없는 조합 컷:
|
||||
# - 항상 노출(게이트 없음) 링크는 permitted 축이 무의미 → na 만 유지, true/false 컷
|
||||
# - 게이트 있는 유저측 링크는 na 컷 (true/false 만)
|
||||
exclusions:
|
||||
- { link: admin_sidebar_to_user_site, permitted: true, reason: "관리자 전용 화면, 게이트 없음" }
|
||||
- { link: admin_sidebar_to_user_site, permitted: false, reason: "관리자 전용 화면, 게이트 없음" }
|
||||
- { link: admin_board_to_user_board, permitted: true, reason: "관리자 화면, 항상 노출" }
|
||||
- { link: admin_board_to_user_board, permitted: false, reason: "관리자 화면, 항상 노출" }
|
||||
- { link: admin_product_to_user_detail, permitted: true, reason: "관리자 화면, 편집모드 노출(권한 무관)" }
|
||||
- { link: admin_product_to_user_detail, permitted: false, reason: "관리자 화면, 편집모드 노출(권한 무관)" }
|
||||
- { link: user_board_to_admin, permitted: na, reason: "게이트 있음 — true/false 만 유효" }
|
||||
- { link: user_product_to_admin_edit, permitted: na, reason: "게이트 있음 — true/false 만 유효" }
|
||||
|
||||
# 후속 효과 체인 — 각 링크가 실제로 수행/노출하는 결과
|
||||
effects:
|
||||
- new_tab_navigation
|
||||
- board_admin_gate_true_shows_link
|
||||
- board_admin_gate_false_hides_link
|
||||
- product_edit_gate_true_shows_link
|
||||
- product_edit_gate_false_hides_link
|
||||
- drawer_starts_below_header
|
||||
# 라우팅 식별자 전환 (#450): 상품=product_code, 게시판=slug
|
||||
- product_detail_routes_by_code
|
||||
- board_admin_routes_by_slug
|
||||
|
||||
# 보조 흐름
|
||||
sub_flows:
|
||||
- id: mobile_drawer
|
||||
effects: [drawer_starts_below_header]
|
||||
Reference in New Issue
Block a user