diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 8e4e8ed..e87d3d9 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -149,3 +149,16 @@ jobs:
echo "Service verification failed (home: $HOME_STATUS, login: $LOGIN_STATUS, auth: $AUTH_BODY)" >&2
exit 1
fi
+
+ - name: Install Playwright dependencies
+ run: |
+ set -e
+ npm ci
+ npx playwright install chromium --with-deps
+
+ - name: Browser E2E verification
+ env:
+ E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
+ E2E_ADMIN_USERNAME: admin
+ E2E_ADMIN_PASSWORD: ${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}
+ run: npm run test:e2e
diff --git a/.gitignore b/.gitignore
index f9c4f50..7f87f78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,9 @@ artifacts/
# Test results
TestResults/
*.trx
+playwright-report/
+test-results/
+.playwright-cli/
# IDE
.vscode/
@@ -46,6 +49,9 @@ Thumbs.db
packages/
.nuget/
+# Node / Playwright
+node_modules/
+
# Publish
publish/
PublishProfiles/
diff --git a/ROADMAP_WBS.md b/ROADMAP_WBS.md
new file mode 100644
index 0000000..3f75b0b
--- /dev/null
+++ b/ROADMAP_WBS.md
@@ -0,0 +1,92 @@
+# TaxBaik 개선 로드맵 WBS
+
+이 문서는 "완료 보고"가 아니라 검증 가능한 작업 목록이다. 각 WBS는 성공 기준을 통과해야 완료로 본다.
+
+## 완료 판정 원칙
+
+- 코드 변경만으로 완료 처리하지 않는다.
+- 서버 배포 대상 기능은 CI/CD 성공과 Playwright 브라우저 테스트 통과를 모두 요구한다.
+- API 기능은 단위 테스트 또는 통합 테스트와 함께 실제 HTTP 호출 결과를 확인한다.
+- DB 변경은 마이그레이션과 롤백 위험을 문서화한다.
+- 비밀값은 Gitea Secrets 또는 서버 환경변수로만 관리한다.
+
+## WBS-OPS-01 배포 검증 고도화
+
+목표: curl/API 검증만으로 "완료" 처리하지 않고, 실제 브라우저 사용자 흐름을 CI 게이트로 만든다.
+
+성공 기준:
+- `dotnet build TaxBaik.sln -c Release` 경고 0, 오류 0
+- `dotnet test TaxBaik.sln -c Release --no-build` 전체 통과
+- CI 배포 후 Playwright가 `/taxbaik/admin/login`에서 실제 로그인 수행
+- 로그인 후 `/taxbaik/admin/dashboard` 도달
+- `localStorage.auth_token` 저장 확인
+- 브라우저 console error 및 page error 0개
+
+Todo:
+- [x] Playwright Test 프로젝트 추가
+- [x] 관리자 로그인 E2E 추가
+- [x] CI 배포 후 Playwright 실행 단계 추가
+- [x] Playwright가 발견한 Blazor DI 결함 수정
+- [ ] CI run에서 Playwright 통과 확인
+
+## WBS-AUTH-01 인증/비밀번호 운영 안정화
+
+목표: DB 직접 수정 대신 API로 관리자 인증 운영 작업을 수행한다.
+
+성공 기준:
+- 비밀번호 변경 API가 현재 비밀번호를 요구한다.
+- 비밀번호 재설정 API는 운영 secret 없이는 동작하지 않는다.
+- 실패 응답은 민감 정보를 노출하지 않는다.
+- Playwright 로그인 테스트가 변경 후에도 통과한다.
+
+Todo:
+- [x] 로그인 API 검증
+- [x] 비밀번호 변경 API 추가
+- [x] 재설정 API 추가
+- [ ] 관리자 UI에 비밀번호 변경 화면 추가
+- [ ] 비밀번호 변경 Playwright E2E 추가
+
+## WBS-ADMIN-01 관리자 Blazor 안정화
+
+목표: 관리자 화면을 일반 웹페이지처럼 명시적 사용자 액션에만 갱신하고, circuit 예외를 배포 전 차단한다.
+
+성공 기준:
+- 관리자 주요 메뉴 대시보드/블로그/문의/설정 접근 시 circuit error 0개
+- 잘못된 DI 타입 주입 0건
+- 저장/삭제/상태 변경 액션은 성공/실패 메시지를 표시한다.
+- Playwright가 주요 메뉴 smoke test를 수행한다.
+
+Todo:
+- [x] 중복 `/admin` 라우트 제거
+- [x] MudBlazor DI 타입 오류 수정
+- [ ] 관리자 메뉴 smoke E2E 추가
+- [ ] 설정 저장 TODO를 실제 DB 기반 기능으로 전환
+
+## WBS-UX-01 공개 홈페이지 UX/SEO 검증
+
+목표: 공개 홈페이지가 검색 유입과 상담 전환에 맞는 구조인지 검증한다.
+
+성공 기준:
+- 홈/블로그 목록/블로그 상세/상담 문의 페이지 200
+- 주요 페이지 title/description 존재
+- 모바일 viewport에서 주요 CTA가 보인다.
+- 상담 문의 제출 Playwright E2E가 통과한다.
+
+Todo:
+- [ ] 공개 페이지 Playwright smoke E2E 추가
+- [ ] 상담 문의 제출 E2E 추가
+- [ ] 블로그 상세 SEO 메타 검증 추가
+
+## WBS-MAINT-01 유지보수성/파편화 축소
+
+목표: 문서와 실제 구조의 불일치를 줄이고 단일 앱 운영 기준을 유지한다.
+
+성공 기준:
+- README/CLAUDE/DEPLOYMENT_GUIDE의 .NET 버전, 앱 구조, 테스트 기준이 실제 코드와 일치
+- 배포 문서에 Playwright 검증 절차 포함
+- 오래된 분리 Admin 서비스 문서 제거 또는 명확히 deprecated 처리
+
+Todo:
+- [ ] README 테스트/배포 섹션 갱신
+- [ ] CLAUDE.md E2E 기준 갱신
+- [ ] 오래된 최종 보고 문서의 허위 완료 표현 정정
diff --git a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor
index b439879..9ba4ac2 100644
--- a/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/Blog/BlogList.razor
@@ -1,7 +1,6 @@
@page "/admin/blog"
@attribute [Authorize]
@inject IApiClient ApiClient
-@inject DialogService DialogService
@inject ISnackbar Snackbar
블로그 관리
diff --git a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor
index 49df900..46c42c9 100644
--- a/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor
+++ b/TaxBaik.Web/Components/Admin/Pages/Settings/SiteSettings.razor
@@ -1,7 +1,7 @@
@page "/admin/settings"
@using TaxBaik.Domain.Interfaces
@attribute [Authorize]
-@inject Snackbar Snackbar
+@inject ISnackbar Snackbar
설정
@@ -35,5 +35,7 @@
private async Task SaveSettings()
{
// TODO: Save settings to database
+ Snackbar.Add("설정 저장 기능은 아직 구현되지 않았습니다.", Severity.Info);
+ await Task.CompletedTask;
}
}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..2128239
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,75 @@
+{
+ "name": "taxbaik",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@playwright/test": "1.57.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
+ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.57.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
+ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.57.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.57.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
+ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c960e2f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "scripts": {
+ "test:e2e": "playwright test",
+ "test:e2e:headed": "playwright test --headed"
+ },
+ "devDependencies": {
+ "@playwright/test": "1.57.0"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..1d495b5
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ timeout: 30_000,
+ expect: {
+ timeout: 10_000
+ },
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
+ use: {
+ baseURL: process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik',
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure'
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] }
+ }
+ ]
+});
diff --git a/tests/e2e/admin-login.spec.ts b/tests/e2e/admin-login.spec.ts
new file mode 100644
index 0000000..75a045b
--- /dev/null
+++ b/tests/e2e/admin-login.spec.ts
@@ -0,0 +1,36 @@
+import { expect, test } from '@playwright/test';
+
+const username = process.env.E2E_ADMIN_USERNAME ?? 'admin';
+const password = process.env.E2E_ADMIN_PASSWORD;
+const baseUrl = (process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik').replace(/\/$/, '');
+
+test.describe('admin authentication', () => {
+ test('logs in through the real browser UI and reaches dashboard', async ({ page }) => {
+ test.skip(!password, 'E2E_ADMIN_PASSWORD is required.');
+
+ const consoleErrors: string[] = [];
+ page.on('console', message => {
+ if (message.type() === 'error') {
+ consoleErrors.push(message.text());
+ }
+ });
+ page.on('pageerror', error => {
+ consoleErrors.push(error.message);
+ });
+
+ await page.goto(`${baseUrl}/admin/login`);
+
+ await expect(page.getByRole('heading', { name: '관리자 로그인' })).toBeVisible();
+ await page.getByRole('textbox', { name: '사용자명' }).fill(username);
+ await page.getByRole('textbox', { name: '비밀번호' }).fill(password);
+ await page.getByRole('button', { name: '로그인' }).click();
+
+ await expect(page).toHaveURL(/\/taxbaik\/admin\/dashboard$/);
+ await expect(page.getByRole('heading', { name: /대시보드/ })).toBeVisible();
+ await expect(page.getByRole('link', { name: /로그아웃/ })).toBeVisible();
+
+ const token = await page.evaluate(() => localStorage.getItem('auth_token'));
+ expect(token, 'auth_token should be stored after login').toBeTruthy();
+ expect(consoleErrors, 'browser console/page errors').toEqual([]);
+ });
+});