feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
StorageInterface::url 의 public 하드코딩을 완화하고 core.storage.filter_url 훅을 신설해, 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)의 직접 URL 서빙을 옵트인으로 열었다. 미설정 시 기존 스트리밍 100% 보존. 디스크 결정은 확장 개별 설정 > 코어 전역 > 미설정 순이며, 서빙·삭제·이동은 행에 기록된 disk 를 따라 전환 전후 자산이 혼재해도 각자 실제 저장 위치를 향한다. 고아 disk(공급 플러그인 비활성화)는 스트리밍으로 폴백해 500 이 되지 않는다. 공개 이슈 (@lyg-kaban 건의) 대응.
This commit is contained in:
@@ -9,13 +9,14 @@
|
||||
### Added
|
||||
|
||||
- 환경설정 > 드라이버 > 파일 스토리지에 S3 호환 스토리지 설정이 추가되었습니다 — 엔드포인트 URL 입력칸과 Path-style 주소 토글로 Cloudflare R2, MinIO 등도 연결할 수 있습니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 환경설정 > 드라이버 탭에 「공개 자산 스토리지」 카드가 추가되었습니다. 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)을 직접 URL(CDN)로 서빙할 디스크를 고를 수 있으며, 기본값(사용 안 함)이면 종전 스트리밍 방식 그대로입니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
|
||||
|
||||
### Changed
|
||||
|
||||
- S3 리전이 5개 목록 선택에서 자유 입력으로 바뀌었습니다 — 새로 생기는 AWS 리전이나 R2 의 `auto` 값을 그대로 입력할 수 있습니다.
|
||||
- S3 URL 항목의 이름과 설명을 "공개 URL(CDN)" 용도로 명확히 했습니다 — API 요청 주소는 새로 생긴 엔드포인트 URL 칸에 입력합니다.
|
||||
- 웹소켓 연결 테스트가 서버 발송용 주소까지 함께 검사하도록 테스트 요청 항목이 확장되었습니다.
|
||||
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다 (S3 엔드포인트·리전 자유 입력 등 신규 설정 키가 코어 7.0.7 부터 지원됩니다).
|
||||
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다 (S3 엔드포인트·리전 자유 입력, 공개 자산 스토리지 등 신규 설정 키가 코어 7.0.7 부터 지원됩니다).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 드라이버 탭 — 공개 자산 스토리지 카드 테스트 (공개#100)
|
||||
*
|
||||
* @description
|
||||
* 신규 "공개 자산 스토리지" 카드는 storage 카드(#99 작업 구역)의 **형제**로 존재해야
|
||||
* 하며, Select 는 저장 키(drivers.public_asset_disk)와 서버 카탈로그
|
||||
* (available_drivers.public_asset)를 정확히 바인딩해야 한다. 화면 옵션이 카탈로그를
|
||||
* 벗어나면 저장 단계(레지스트리 조회 closure rule)에서 422 가 된다.
|
||||
*
|
||||
* 계약 단언(JSON 구조 + 저장 규칙 대조)에 더해, 실제 카드 서브트리를
|
||||
* createLayoutTest 로 렌더링해 옵션 계산식($localized 매핑)과 s3 도움말 조건부
|
||||
* 렌더링까지 검증한다.
|
||||
*
|
||||
* @effects settings_catalog_includes_plugin_registered_disks
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
|
||||
|
||||
import tabDrivers from '../../layouts/partials/admin_settings/_tab_drivers.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 TestH3: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
|
||||
({ className, children, text }) => <h3 className={className}>{children || text}</h3>;
|
||||
|
||||
const TestLabel: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
|
||||
({ className, children, text }) => <label className={className}>{children || text}</label>;
|
||||
|
||||
const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
|
||||
({ className, children, text }) => <span className={className}>{children || text}</span>;
|
||||
|
||||
const TestSelect: React.FC<{
|
||||
name?: string; value?: string; className?: string; options?: any[]; disabled?: boolean;
|
||||
error?: string; onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
}> = ({ name, value, className, options, disabled, onChange }) => (
|
||||
<select data-name={name} value={value} className={className} disabled={disabled} onChange={onChange}>
|
||||
{options?.map((o) => <option key={String(o.value)} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
|
||||
// 렌더러가 컴포넌트 목록을 Fragment 로 감싸므로 반드시 등록해야 한다.
|
||||
// 미등록 시 트리 전체가 렌더되지 않아 "없어야 한다" 계열 단언이 거짓 통과한다.
|
||||
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
|
||||
|
||||
function setupTestRegistry(): ComponentRegistry {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
(registry as any).registry = {
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
|
||||
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } },
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ─── 다국어 (드라이버 탭 공개 자산 카드가 쓰는 키) ───
|
||||
|
||||
const translations = {
|
||||
admin: {
|
||||
settings: {
|
||||
drivers: {
|
||||
public_asset: {
|
||||
title: '공개 자산 스토리지',
|
||||
desc: '완전 공개 자산의 직접 URL 서빙 디스크를 설정합니다.',
|
||||
disk: '공개 자산 디스크',
|
||||
s3_help: '스토리지 카드의 S3 URL 설정이 필요합니다.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 저장소 루트를 위로 훑어 찾는다.
|
||||
*
|
||||
* @returns 저장소 루트 절대경로
|
||||
*/
|
||||
function repositoryRoot(): string {
|
||||
let current = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
for (let depth = 0; depth < 10; depth++) {
|
||||
if (fs.existsSync(path.join(current, 'config/core.php'))) {
|
||||
return current;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
throw new Error('저장소 루트를 찾지 못했습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다.
|
||||
*
|
||||
* @param node 탐색 시작 노드
|
||||
* @param predicate 노드 판정 함수
|
||||
* @returns 찾은 노드 또는 null
|
||||
*/
|
||||
function findNode(node: any, predicate: (n: any) => boolean): any {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (predicate(node)) return node;
|
||||
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findNode(child, predicate);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 실제 카드 서브트리를 그대로 렌더 대상으로 쓴다 — 사본 표현식이 아닌 배포 JSON 자체가 SSoT.
|
||||
const publicAssetCard = (tabDrivers as any).children.find(
|
||||
(c: any) => c.id === 'card_public_asset_settings',
|
||||
);
|
||||
|
||||
const CATALOG = [
|
||||
{ id: 'none', label: { ko: '사용 안 함 (스트리밍)', en: 'None (streaming)' } },
|
||||
{ id: 'public', label: { ko: '로컬 공개 디스크', en: 'Local public disk' } },
|
||||
{ id: 'fake_cdn', label: { ko: '가짜 CDN', en: 'Fake CDN' } },
|
||||
];
|
||||
|
||||
/**
|
||||
* 카드 렌더링용 테스트 유틸을 생성한다.
|
||||
*
|
||||
* @param selectedDisk 현재 저장된 공개 자산 디스크 값
|
||||
* @returns createLayoutTest 유틸
|
||||
*/
|
||||
function makeUtils(selectedDisk: string) {
|
||||
return createLayoutTest(
|
||||
{
|
||||
version: '1.0.0',
|
||||
layout_name: 'test_public_asset_storage_card',
|
||||
components: [publicAssetCard],
|
||||
} as any,
|
||||
{
|
||||
auth: {
|
||||
isAuthenticated: true,
|
||||
user: { id: 1, name: 'Admin', role: 'super_admin' },
|
||||
authType: 'admin',
|
||||
},
|
||||
translations,
|
||||
locale: 'ko',
|
||||
initialState: {
|
||||
_local: {
|
||||
form: {
|
||||
available_drivers: { public_asset: CATALOG },
|
||||
drivers: { public_asset_disk: selectedDisk },
|
||||
},
|
||||
errors: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('드라이버 탭 공개 자산 스토리지 카드 — 계약', () => {
|
||||
it('storage 카드의 형제로 카드가 존재한다 (#99 s3 블록 비접촉)', () => {
|
||||
const ids = (tabDrivers as any).children.map((c: any) => c.id);
|
||||
|
||||
expect(ids).toContain('card_storage_settings');
|
||||
expect(ids).toContain('card_public_asset_settings');
|
||||
// storage 카드 내부가 아닌 최상위 형제 배열에 위치
|
||||
expect(ids.indexOf('card_public_asset_settings')).toBeGreaterThan(
|
||||
ids.indexOf('card_storage_settings'),
|
||||
);
|
||||
});
|
||||
|
||||
it('Select 가 저장 키와 카탈로그를 정확히 바인딩한다', () => {
|
||||
const select = findNode(publicAssetCard, (n) => n.name === 'Select');
|
||||
|
||||
expect(select).not.toBeNull();
|
||||
expect(select.props.name).toBe('drivers.public_asset_disk');
|
||||
expect(select.props.options).toContain('available_drivers?.public_asset');
|
||||
expect(select.props.error).toContain("['drivers.public_asset_disk']");
|
||||
});
|
||||
|
||||
it('s3 선택 시에만 도움말이 노출된다', () => {
|
||||
const help = findNode(publicAssetCard, (n) => n.id === 'public_asset_s3_help');
|
||||
|
||||
expect(help).not.toBeNull();
|
||||
expect(help.if).toContain("public_asset_disk === 's3'");
|
||||
});
|
||||
|
||||
it('저장 규칙(SaveSettingsRequest)이 같은 키를 검증한다', () => {
|
||||
const requestSource = fs.readFileSync(
|
||||
path.join(repositoryRoot(), 'app/Http/Requests/Settings/SaveSettingsRequest.php'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(requestSource).toContain("'drivers.public_asset_disk'");
|
||||
expect(requestSource).toContain("isDriverAvailable('public_asset'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('드라이버 탭 공개 자산 스토리지 카드 — 렌더링', () => {
|
||||
let registry: ComponentRegistry;
|
||||
let testUtils: ReturnType<typeof createLayoutTest> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = setupTestRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testUtils?.cleanup();
|
||||
testUtils = null;
|
||||
(registry as any).registry = {};
|
||||
});
|
||||
|
||||
it('카드 제목/라벨이 다국어 해석되어 렌더된다', async () => {
|
||||
testUtils = makeUtils('public');
|
||||
const { container } = await testUtils.render();
|
||||
|
||||
expect(container.querySelector('h3.card-title')?.textContent ?? '').toContain(
|
||||
'공개 자산 스토리지',
|
||||
);
|
||||
expect(container.querySelector('label.form-label')?.textContent ?? '').toContain(
|
||||
'공개 자산 디스크',
|
||||
);
|
||||
expect(() => testUtils!.assertNoValidationErrors()).not.toThrow();
|
||||
});
|
||||
|
||||
it('Select 옵션이 서버 카탈로그에서 $localized 매핑으로 계산된다', async () => {
|
||||
testUtils = makeUtils('public');
|
||||
const { container } = await testUtils.render();
|
||||
|
||||
const select = container.querySelector('select[data-name="drivers.public_asset_disk"]');
|
||||
expect(select).not.toBeNull();
|
||||
|
||||
const options = Array.from(select!.querySelectorAll('option'));
|
||||
expect(options.map((o) => o.getAttribute('value'))).toEqual([
|
||||
'none',
|
||||
'public',
|
||||
'fake_cdn',
|
||||
]);
|
||||
// $localized(d.label) — 활성 로케일(ko) 라벨로 해석되어야 한다
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
'사용 안 함 (스트리밍)',
|
||||
'로컬 공개 디스크',
|
||||
'가짜 CDN',
|
||||
]);
|
||||
});
|
||||
|
||||
it('s3 미선택 시 도움말이 렌더되지 않는다', async () => {
|
||||
testUtils = makeUtils('public');
|
||||
const { container } = await testUtils.render();
|
||||
|
||||
// 긍정 앵커 — 카드가 실제로 렌더됐음을 먼저 확인해야 아래 부정 단언이 의미를 갖는다
|
||||
expect(container.querySelector('select[data-name="drivers.public_asset_disk"]')).not.toBeNull();
|
||||
expect(container.textContent ?? '').not.toContain('S3 URL 설정');
|
||||
});
|
||||
|
||||
it('s3 선택 시 도움말이 렌더된다', async () => {
|
||||
testUtils = makeUtils('s3');
|
||||
const { container } = await testUtils.render();
|
||||
|
||||
expect(container.textContent ?? '').toContain('스토리지 카드의 S3 URL 설정이 필요합니다.');
|
||||
});
|
||||
});
|
||||
@@ -1637,6 +1637,12 @@
|
||||
"s3_url": "S3 URL (Public URL)",
|
||||
"s3_url_desc": "CDN or custom domain used to build public file URLs. Enter the API address in the Endpoint URL field instead."
|
||||
},
|
||||
"public_asset": {
|
||||
"title": "Public Asset Storage",
|
||||
"desc": "Configure the disk for serving fully public assets (product, category, review, and editor images, etc.) via direct URLs. When disabled, the existing streaming method is used.",
|
||||
"disk": "Disk",
|
||||
"s3_help": "When selecting S3, the S3 URL (CDN domain) in the File Storage card above must be configured. Without it, streaming is used."
|
||||
},
|
||||
"cache": {
|
||||
"title": "Cache",
|
||||
"desc": "Configure application cache driver settings.",
|
||||
|
||||
@@ -1641,6 +1641,12 @@
|
||||
"s3_url": "S3 URL (공개 URL)",
|
||||
"s3_url_desc": "파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인입니다. API 요청 주소는 엔드포인트 URL에 입력하세요."
|
||||
},
|
||||
"public_asset": {
|
||||
"title": "공개 자산 스토리지",
|
||||
"desc": "완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)의 직접 URL 서빙 디스크를 설정합니다. 사용 안 함이면 기존 스트리밍 방식으로 동작합니다.",
|
||||
"disk": "디스크",
|
||||
"s3_help": "S3 선택 시 위 파일 스토리지 카드의 S3 URL(CDN 도메인) 설정이 필요합니다. 미설정 시 스트리밍으로 동작합니다."
|
||||
},
|
||||
"cache": {
|
||||
"title": "캐시",
|
||||
"desc": "애플리케이션 캐시 드라이버를 설정합니다.",
|
||||
|
||||
+92
@@ -557,6 +557,98 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_public_asset_settings",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "admin-card"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:admin.settings.drivers.public_asset.title"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-description"
|
||||
},
|
||||
"text": "$t:admin.settings.drivers.public_asset.desc"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "field_public_asset_disk",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": ""
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:admin.settings.drivers.public_asset.disk"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "w-full md:w-64"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "drivers.public_asset_disk",
|
||||
"disabled": "{{_computed.isReadOnly}}",
|
||||
"className": "w-full",
|
||||
"error": "{{_local.errors?.['drivers.public_asset_disk']?.[0] ?? ''}}",
|
||||
"options": "{{(_local.form?.available_drivers?.public_asset ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{_local.errors?.['drivers.public_asset_disk']}}",
|
||||
"props": {
|
||||
"className": "form-error"
|
||||
},
|
||||
"text": "{{_local.errors?.['drivers.public_asset_disk']?.[0] ?? ''}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public_asset_s3_help",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.form?.drivers?.public_asset_disk === 's3'}}",
|
||||
"props": {
|
||||
"className": "text-sm text-gray-500 dark:text-gray-400 mt-2"
|
||||
},
|
||||
"text": "$t:admin.settings.drivers.public_asset.s3_help"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_cache_settings",
|
||||
"type": "basic",
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* E2E: 공개 자산 스토리지 설정 — 코어 드라이버 탭 카드 (공개#100)
|
||||
*
|
||||
* 시나리오 매니페스트: `tests/scenarios/public-asset-cdn.yaml`
|
||||
* (케이스 마킹은 각 test 의 라인 주석에 있다)
|
||||
*
|
||||
* @effects settings_catalog_includes_plugin_registered_disks,
|
||||
* invalid_disk_rejected_with_422
|
||||
*
|
||||
* 드라이버 탭의 "공개 자산 스토리지" 카드가 카탈로그(none/public/s3)를 그리고,
|
||||
* 선택-저장-재진입 왕복에서 값이 유지되는지 검증한다. 함께 있는 CKEditor5 축은
|
||||
* 코어 PluginSettingsController 의 카탈로그 부착을 제한 권한에서 검증하는 것이라
|
||||
* 코어/템플릿 표면에 둔다 — 이커머스 모듈이 소유한 오버라이드 필드는 모듈 spec
|
||||
* (`modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/`) 이 담당한다.
|
||||
*
|
||||
* composite Select 는 네이티브 select 가 아니라 커스텀 드롭다운(root div 에 name 속성,
|
||||
* Button 토글, portal listbox role=option)이다 — 상호작용은 그 구조를 따른다.
|
||||
* 저장 후에는 반드시 원값으로 되돌려 사이트 설정을 바꾸지 않는다.
|
||||
*/
|
||||
import { test, expect, authenticatePage, issueToken } from '../fixtures/admin-template-auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** 코어 설정 읽기/쓰기 권한 토큰 (테스트 파일 단위 1회 발급) */
|
||||
let settingsToken: string;
|
||||
|
||||
test.beforeAll(() => {
|
||||
settingsToken = issueToken('core.settings.read', 'core.settings.update');
|
||||
});
|
||||
|
||||
const DRIVERS_TAB = '/admin/settings?tab=drivers';
|
||||
const CKEDITOR5_SETTINGS = '/admin/plugins/sirsoft-ckeditor5/settings';
|
||||
|
||||
const CORE_SELECT_ROOT = '[name="drivers.public_asset_disk"]';
|
||||
const PLUGIN_SELECT_ROOT = '[name="public_asset_disk"]';
|
||||
|
||||
/**
|
||||
* 드라이버 탭 진입 — 폼 데이터 적재를 엔진 상태로 확인한다.
|
||||
*
|
||||
* DOM attach 만 기다리면 바인딩 전 빈 Select 를 카탈로그 누락으로 오인한다.
|
||||
*/
|
||||
async function gotoDriversTab(page: Page): Promise<void> {
|
||||
await page.goto(DRIVERS_TAB);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page.evaluate(() => {
|
||||
const local = (window as any).G7Core?.state?.getLocal?.();
|
||||
return Array.isArray(local?.form?.available_drivers?.public_asset);
|
||||
}),
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 드롭다운을 열고 옵션 라벨 목록을 읽는다 (읽은 뒤 드롭다운은 닫지 않음).
|
||||
*
|
||||
* @param page Playwright 페이지
|
||||
* @param rootSelector Select 루트(name 속성) 선택자
|
||||
* @returns 옵션 라벨 배열
|
||||
*/
|
||||
async function openAndReadOptions(page: Page, rootSelector: string): Promise<string[]> {
|
||||
await page.locator(`${rootSelector} button`).first().click();
|
||||
const listbox = page.locator('[role="listbox"]');
|
||||
await expect(listbox).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
return listbox.locator('[role="option"]').allInnerTexts();
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 로컬 상태에서 폼 값을 읽는다.
|
||||
*
|
||||
* @param page Playwright 페이지
|
||||
* @param path form 하위 도트 경로 (예: 'drivers.public_asset_disk')
|
||||
* @returns 현재 폼 값
|
||||
*/
|
||||
async function readFormValue(page: Page, path: string): Promise<unknown> {
|
||||
return page.evaluate((p) => {
|
||||
const form = (window as any).G7Core?.state?.getLocal?.()?.form;
|
||||
return p.split('.').reduce((acc: any, key: string) => acc?.[key], form);
|
||||
}, path);
|
||||
}
|
||||
|
||||
test.describe('공개 자산 스토리지 설정', () => {
|
||||
// @scenario consumer=product, disk_setting=none, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=legacy_local_row
|
||||
// @effects settings_catalog_includes_plugin_registered_disks
|
||||
test('드라이버 탭 카드가 코어 카탈로그(none/public/s3)를 그린다', async ({ page }) => {
|
||||
await authenticatePage(page, settingsToken);
|
||||
await gotoDriversTab(page);
|
||||
|
||||
await expect(page.locator(CORE_SELECT_ROOT)).toBeVisible();
|
||||
|
||||
const labels = await openAndReadOptions(page, CORE_SELECT_ROOT);
|
||||
expect(labels.join('|')).toContain('사용 안 함');
|
||||
expect(labels.join('|')).toContain('Public');
|
||||
expect(labels.join('|')).toContain('Amazon S3');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// @scenario consumer=product, disk_setting=public, e2e=save_roundtrip, hook=unregistered, override=module_override, row_state=new_remote_row
|
||||
// @effects invalid_disk_rejected_with_422
|
||||
test('선택-저장-재진입 왕복에서 값이 유지되고 원복된다', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await authenticatePage(page, settingsToken);
|
||||
await gotoDriversTab(page);
|
||||
|
||||
const original = (await readFormValue(page, 'drivers.public_asset_disk')) as string;
|
||||
|
||||
// Public 디스크 선택 → 저장 → 성공 토스트
|
||||
await openAndReadOptions(page, CORE_SELECT_ROOT);
|
||||
await page.locator('[role="listbox"] [role="option"]', { hasText: 'Public' }).first().click();
|
||||
await expect.poll(() => readFormValue(page, 'drivers.public_asset_disk')).toBe('public');
|
||||
|
||||
await page.locator('#save_button button, button#save_button').first().click();
|
||||
await expect(page.getByText('설정이 저장되었습니다')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 재진입 — 서버 저장값이 폼으로 복원되는지 확인 (refetchOnMount)
|
||||
await gotoDriversTab(page);
|
||||
await expect.poll(() => readFormValue(page, 'drivers.public_asset_disk'), { timeout: 20_000 }).toBe('public');
|
||||
|
||||
// 원복 (사이트 설정 보존) — 원값 라벨('none' → '사용 안 함')로 되돌린다
|
||||
const restoreLabel = original === 'public' ? 'Public' : original === 's3' ? 'Amazon S3' : '사용 안 함';
|
||||
await openAndReadOptions(page, CORE_SELECT_ROOT);
|
||||
await page.locator('[role="listbox"] [role="option"]', { hasText: restoreLabel }).first().click();
|
||||
await page.locator('#save_button button, button#save_button').first().click();
|
||||
await expect(page.getByText('설정이 저장되었습니다')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await gotoDriversTab(page);
|
||||
await expect
|
||||
.poll(() => readFormValue(page, 'drivers.public_asset_disk'), { timeout: 20_000 })
|
||||
.toBe(original || 'none');
|
||||
});
|
||||
|
||||
// @scenario consumer=editor, disk_setting=none, e2e=ecommerce_settings_field, hook=unregistered, override=module_override, row_state=legacy_local_row
|
||||
// @effects settings_catalog_includes_plugin_registered_disks
|
||||
test('CKEditor5 설정 필드가 코어 설정 권한 없이도 카탈로그를 그린다', async ({ page }) => {
|
||||
// core.settings.read 를 의도적으로 제외 — 카탈로그는 플러그인 설정 응답에 서버
|
||||
// 부착되므로, 코어 환경설정 API 권한이 없는 커스텀 역할에서도 선택지가 채워져야
|
||||
// 한다 (화면 권한과 카탈로그 권한의 표면 단일화 검증)
|
||||
const pluginOnlyToken = issueToken('core.plugins.read', 'core.plugins.update');
|
||||
await authenticatePage(page, pluginOnlyToken);
|
||||
await page.goto(CKEDITOR5_SETTINGS);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page.evaluate(() => {
|
||||
const local = (window as any).G7Core?.state?.getLocal?.();
|
||||
return Array.isArray(local?.form?.available_public_asset_disks);
|
||||
}),
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await expect(page.locator(PLUGIN_SELECT_ROOT)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const labels = await openAndReadOptions(page, PLUGIN_SELECT_ROOT);
|
||||
expect(labels[0]).toContain('코어 설정 따름');
|
||||
expect(labels.join('|')).toContain('사용 안 함');
|
||||
expect(labels.join('|')).toContain('Amazon S3');
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user