diff --git a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md index 38201236..22fcfab5 100644 --- a/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-gdpr/CHANGELOG.md @@ -27,6 +27,7 @@ - 관리자 환경설정의 카테고리 「필수」 배지가 다크 모드에서 배경·글자색이 적용되지 않던 문제를 바로잡았습니다. - 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다. - 관리자 환경설정의 저장/취소 버튼을 다른 관리자 화면과 동일하게 화면 하단 고정 위치로 옮기고, 상단 탭 영역의 불필요한 구분선과 좌우 여백 어긋남을 바로잡았습니다. +- 관리자 환경설정 「정책 버전 보기」에서 본문 페이지 링크를 클릭하면 페이지를 찾을 수 없던 문제를 바로잡았습니다. ## [1.0.1] - 2026-07-16 diff --git a/plugins/_bundled/sirsoft-gdpr/package.json b/plugins/_bundled/sirsoft-gdpr/package.json index 31c6df25..0ee7e653 100644 --- a/plugins/_bundled/sirsoft-gdpr/package.json +++ b/plugins/_bundled/sirsoft-gdpr/package.json @@ -7,7 +7,9 @@ "build": "vite build", "dev": "vite build --watch", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts", + "test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui" }, "devDependencies": { "@testing-library/jest-dom": "^6.5.0", diff --git a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx index 0b6b3e4f..b58870b1 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-gdpr/resources/js/__tests__/layouts/admin_plugin_settings.test.tsx @@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest'; import layout from '../../../layouts/admin/plugin_settings.json'; import publishModal from '../../../layouts/admin/partials/plugin_settings/_policy_version_publish_modal.json'; +import snapshotModal from '../../../layouts/admin/partials/_shared/_policy_version_snapshot_modal.json'; import { findById, type AnyNode } from './helpers'; describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / auto_blocking_policy)', () => { @@ -698,3 +699,26 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au }); }); }); + +/** + * 이슈 #509 체크리스트 12번 회귀 테스트 — 정책 버전 snapshot 모달의 본문 페이지 링크가 + * `/{{slug}}` 로 조합되어 페이지 모듈(sirsoft-page) 실제 URL 패턴 `/page/{slug}` 와 + * 불일치, 클릭 시 404 발생하던 결함. 쿠키 배너의 '변경된 정책 본문 보기' 링크와 + * 동일하게 `/page/{{slug}}` 로 조합되어야 한다. + */ +describe('admin/partials/_shared/_policy_version_snapshot_modal.json — 본문 페이지 링크', () => { + const layoutJson = JSON.stringify(snapshotModal); + + it('본문 페이지 링크 href 가 /page/{{slug}} 형식으로 조합된다 (페이지 모듈 URL 패턴 일치)', () => { + expect(layoutJson).toContain('"href":"/page/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug ?? \'\'}}"'); + }); + + it('href 에 /page/ 프리픽스 없이 슬러그만 조합하는 결함 패턴이 없다', () => { + expect(layoutJson).not.toContain('"href":"/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug}}"'); + }); + + it('본문 페이지 링크는 새 탭(target=_blank) + noopener 로 연다', () => { + expect(layoutJson).toContain('"target":"_blank"'); + expect(layoutJson).toContain('"rel":"noopener"'); + }); +}); diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json index 430a0d2a..be293185 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/partials/_shared/_policy_version_snapshot_modal.json @@ -172,11 +172,12 @@ "text": "{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug ?? '-'}}" }, { - "comment": "본문 페이지 새 탭 열기 — 슬러그를 그대로 path 로 사용 (예: 'privacy-policy' → '/privacy-policy'). 운영자가 검증한 슬러그라 사용자 입력 검증 불요.", + "id": "policy_version_snapshot_open_policy_page_link", + "comment": "본문 페이지 새 탭 열기 — 페이지 모듈(sirsoft-page) URL 패턴 /page/{slug} 로 조합 (예: 'privacy-policy' → '/page/privacy-policy'). 쿠키 배너의 '변경된 정책 본문 보기' 링크(cookie_banner.json)와 동일한 조합 방식. 운영자가 검증한 슬러그라 사용자 입력 검증 불요.", "type": "basic", "name": "A", "props": { - "href": "/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug}}", + "href": "/page/{{gdprPolicyVersionSnapshot?.data?.data?.snapshot?.privacy_policy_slug ?? ''}}", "target": "_blank", "rel": "noopener", "className": "text-info-soft inline-flex items-center gap-1 px-3 py-2 font-medium bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors flex-shrink-0" diff --git a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json index 859aedef..5f8a16a8 100644 --- a/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/plugin_settings.json @@ -755,6 +755,7 @@ "text": "v{{gdprPolicyVersionCurrent?.data?.data?.version ?? '?'}}" }, { + "id": "policy_version_current_snapshot_view_button", "type": "basic", "name": "Button", "if": "{{gdprPolicyVersionCurrent?.data?.data?.version}}", diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-auth.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-auth.ts new file mode 100644 index 00000000..27d87758 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/fixtures/gdpr-auth.ts @@ -0,0 +1,24 @@ +/** + * GDPR 플러그인 권한 fixture. + * + * 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼를 재사용하되, 플러그인 권한 토큰 fixture 를 + * 자체적으로 정의한다. 권한 식별자는 임의 string 이므로 코어 PlaywrightIssueToken 커맨드가 + * 그대로 동작한다 (Permission::firstOrCreate 가 자동 생성). + */ +import { test as base } from '@playwright/test'; +// 6단계 상위 = 코어 루트의 fixtures/auth.ts +import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth'; + +type GdprAuthFixtures = { + /** GDPR 개인정보 조회 + 설정 변경 권한 보유 토큰 (환경설정 화면 접근용) */ + privacyManageToken: string; +}; + +export const test = base.extend({ + privacyManageToken: async ({}, use) => { + await use(issueToken('sirsoft-gdpr.privacy.view', 'sirsoft-gdpr.privacy.update')); + }, +}); + +export { authenticatePage }; +export { expect } from '@playwright/test'; diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/playwright.config.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/playwright.config.ts new file mode 100644 index 00000000..e932044a --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/playwright.config.ts @@ -0,0 +1,84 @@ +/** + * GDPR 플러그인 Playwright E2E 설정. + * + * 코어 `playwright.config.ts` 와 동일한 base URL 해석 우선순위를 따른다 — 플러그인도 활성 호스트가 + * 가변(개발자/CI/PO 환경별로 다른 도메인)이므로 하드코딩 회피. + * + * Base URL 해석: + * 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드) + * 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합 + * 3. 그 외 — 명시 에러 + * + * 실행 예시: + * PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npm run test:e2e + * Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npm run test:e2e + * + * 플러그인은 코어 fixture 의 `issueToken` / `authenticatePage` 헬퍼를 재사용 — 권한 식별자는 + * `sirsoft-gdpr.*` 등 임의 string. + */ +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync, existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로 +// import.meta.url 로 재구성한다. +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로). + * + * 확장 config 는 확장 디렉토리에서 실행되지만, 산출물을 그 안에 쓰면 Windows 에서 + * `plugin:update` 의 디렉토리 이동이 열린 핸들에 걸려 실패한다. + * 산출물은 코어 루트 아래로 모아 update 경로와 분리한다 (.gitignore 가 이미 덮는 위치). + */ +const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../'); + +/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */ +const ARTIFACT_SLUG = 'plugins/sirsoft-gdpr'; + +function readEnvFile(filePath: string, key: string): string | null { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath, { encoding: 'utf-8' }); + const pattern = new RegExp(`^${key}=(.*)$`, 'm'); + const match = content.match(pattern); + if (!match) return null; + let value = match[1].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return value || null; +} + +function resolveBaseUrl(): string { + if (process.env.PLAYWRIGHT_BASE_URL) { + return process.env.PLAYWRIGHT_BASE_URL; + } + const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL'); + if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) { + return appUrl; + } + throw new Error( + 'GDPR 플러그인 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.' + ); +} + +export default defineConfig({ + testDir: './specs', + outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG), + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }], + ['list'], + ], + use: { + baseURL: resolveBaseUrl(), + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts new file mode 100644 index 00000000..73927d64 --- /dev/null +++ b/plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/admin/policy-version-snapshot-link.spec.ts @@ -0,0 +1,53 @@ +/** + * E2E: 관리자 환경설정 > 쿠키 배너 > 정책 버전 > 보기 — 본문 페이지 링크 (#509 체크리스트 12번) + * + * @scenario admin_gdpr_policy_version_snapshot_open_policy_page_link + * @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern + * + * 배경: 정책 버전 snapshot 모달의 본문 페이지 링크가 `/{{slug}}` 로 조합되어 페이지 모듈 + * (sirsoft-page) 의 실제 URL 패턴 `/page/{slug}` 와 불일치, 클릭 시 404 가 발생하던 결함. + * 마이그레이션이 시드하는 v1 initial 정책 버전의 snapshot 에는 항상 기본 슬러그 'privacy' 가 + * 포함되므로 (GdprPolicyVersionMigrationSmokeTest 참조), 별도 설정 저장 없이도 v배지 클릭 → + * 모달의 본문 페이지 링크 href 를 검증할 수 있다. + * + * 검증: + * 1. 환경설정 화면에서 v배지 옆 "본문 보기" 버튼 클릭 시 snapshot 모달이 열린다 + * 2. 모달의 본문 페이지 링크 href 가 `/page/{slug}` 형식으로 조합되어 페이지 모듈 URL 패턴과 일치한다 + * (프리픽스 누락으로 `/{slug}` 형태가 되는 결함 패턴이 재발하지 않았는지 회귀 가드) + */ +import { test, expect, authenticatePage } from '../../fixtures/gdpr-auth'; + +const CARD_COOKIE_BANNER = '#card_cookie_banner'; +const SNAPSHOT_VIEW_BUTTON = '#policy_version_current_snapshot_view_button'; +const OPEN_POLICY_PAGE_LINK = '#policy_version_snapshot_open_policy_page_link'; + +/** 관리자 GDPR 환경설정 진입 후 쿠키 배너 탭(정책 버전 UI 포함)으로 스크롤 이동 */ +async function gotoGdprSettings(page: import('@playwright/test').Page): Promise { + await page.goto('/admin/plugins/sirsoft-gdpr/settings'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator('#settings_tab_navigation')).toBeAttached({ timeout: 20_000 }); + + // TabNavigationScroll 은 URL query 가 아니라 클릭 시 해당 카드로 스크롤하는 방식이다. + await page.locator(CARD_COOKIE_BANNER).scrollIntoViewIfNeeded(); + await expect(page.locator(SNAPSHOT_VIEW_BUTTON)).toBeAttached({ timeout: 20_000 }); +} + +// @scenario tab=card_cookie_banner, permitted=yes +// @effects snapshot_modal_opens, policy_page_link_href_matches_page_module_url_pattern +test('#509 - 정책 버전 snapshot 모달의 본문 페이지 링크가 /page/{slug} 로 조합된다', async ({ page, privacyManageToken }) => { + await authenticatePage(page, privacyManageToken); + + await gotoGdprSettings(page); + expect(page.url()).not.toMatch(/\/admin\/login/); + + await page.locator(SNAPSHOT_VIEW_BUTTON).click(); + + const link = page.locator(OPEN_POLICY_PAGE_LINK); + await expect(link).toBeAttached({ timeout: 10_000 }); + + const href = await link.getAttribute('href'); + expect(href).not.toBeNull(); + // 결함 패턴(/{slug}, /page/ 프리픽스 누락) 재발 방지 — 페이지 모듈 URL 패턴 /page/{slug} 와 일치해야 한다. + expect(href).toMatch(/^\/page\/.+/); + expect(href).not.toMatch(/^\/(?!page\/)/); +});