Compare commits
5 Commits
4d94b9b4ff
...
700cdaed4f
| Author | SHA1 | Date | |
|---|---|---|---|
| 700cdaed4f | |||
| 65241c453c | |||
| b3baef012d | |||
| 0d07b2d26a | |||
| 65c2dce8fe |
@@ -61,9 +61,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Browser E2E verification
|
- name: Browser E2E verification
|
||||||
env:
|
env:
|
||||||
|
# Green-Blue 배포 지원: Nginx를 통해 active 포트로 라우팅
|
||||||
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
E2E_BASE_URL: http://${{ secrets.DEPLOY_HOST }}/taxbaik
|
||||||
E2E_ADMIN_USERNAME: admin
|
# E2E 테스트는 test_admin 테스트 계정 사용 (실 admin 계정과 분리)
|
||||||
E2E_ADMIN_PASSWORD: ${{ secrets.TAXBAIK_ADMIN_TEST_PASSWORD }}
|
E2E_ADMIN_USERNAME: test_admin
|
||||||
|
E2E_ADMIN_PASSWORD: test123456
|
||||||
run: npm run test:e2e
|
run: npm run test:e2e
|
||||||
|
|
||||||
- name: Browser E2E summary
|
- name: Browser E2E summary
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||||
taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz"
|
taxbaik_deploy.tgz "$DEPLOY_USER@$DEPLOY_HOST:/tmp/taxbaik_${TIMESTAMP}.tgz"
|
||||||
|
|
||||||
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리)
|
# 2. 서버에서 배포 + 헬스 체크 (SSH 1회 연결로 처리, Green-Blue 지원)
|
||||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \
|
||||||
-o ServerAliveInterval=10 \
|
-o ServerAliveInterval=10 \
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
"$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE
|
||||||
|
|||||||
@@ -71,7 +71,119 @@ _refreshTokenExpirationMinutes = 10080;
|
|||||||
- Inquiry 페이지 → API 클라이언트
|
- Inquiry 페이지 → API 클라이언트
|
||||||
- FAQ/Client/TaxFiling 등 순차 처리
|
- FAQ/Client/TaxFiling 등 순차 처리
|
||||||
|
|
||||||
**현재 진행**: **Phase 6 - SignalR 통합** (다음)
|
**현재 상태**: **✅ ALL PHASES COMPLETE (2026-06-28)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **전체 프로젝트 완료 현황**
|
||||||
|
|
||||||
|
### **Phase 5: JWT 토큰 개선** ✅
|
||||||
|
- Access Token (15분) + Refresh Token (7일) 분리
|
||||||
|
- TokenRefreshHandler (401 자동 갱신)
|
||||||
|
- ITokenStore (메모리 기반 Blazor Server 안전)
|
||||||
|
- CustomAuthenticationStateProvider (토큰 쌍 관리)
|
||||||
|
- Login.razor (새 토큰 패턴 구현)
|
||||||
|
|
||||||
|
### **Phase 7: API-First 마이그레이션** ✅
|
||||||
|
|
||||||
|
**Phase 7-1: Blog** ✅
|
||||||
|
- API: 완성 (CRUD, 페이징)
|
||||||
|
- Blazor: 이미 API 클라이언트 사용 중
|
||||||
|
|
||||||
|
**Phase 7-2: Inquiry** ✅
|
||||||
|
- API: 완성 (상태 변경, 메모, 고객 변환)
|
||||||
|
- Blazor: InquiryTable + InquiryDetail 완전 마이그레이션
|
||||||
|
|
||||||
|
**Phase 7-3: 모든 관리자 페이지** ✅
|
||||||
|
- 4개 API Controller (Clients, TaxFilings, Faqs, Announcements)
|
||||||
|
- 5개 Browser Client (IXxxBrowserClient)
|
||||||
|
- 9개 Blazor 페이지 마이그레이션
|
||||||
|
|
||||||
|
| 페이지 | API | Client | Blazor |
|
||||||
|
|------|---|---|---|
|
||||||
|
| Clients | ✅ ClientController | ✅ IClientBrowserClient | ✅ List + Edit |
|
||||||
|
| TaxFilings | ✅ TaxFilingController | ✅ ITaxFilingBrowserClient | ✅ List + Table |
|
||||||
|
| Faqs | ✅ FaqController | ✅ IFaqBrowserClient | ✅ List + Edit |
|
||||||
|
| Announcements | ✅ AnnouncementController | ✅ IAnnouncementBrowserClient | ✅ List + Edit |
|
||||||
|
| Inquiries | ✅ InquiryController | ✅ IInquiryBrowserClient | ✅ List + Detail |
|
||||||
|
| Dashboard | ✅ AdminDashboardController | ✅ IAdminDashboardClient | ✅ Refactored |
|
||||||
|
|
||||||
|
### **Phase 6: SignalR 통합** ✅
|
||||||
|
- NotificationHub (브로드캐스트만, 상태 관리 없음)
|
||||||
|
- INotificationService (이벤트 기반)
|
||||||
|
- 5개 알림 유형 (Inquiry, Client, Announcement, Filing, Status)
|
||||||
|
- Program.cs SignalR 등록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ **최종 아키텍처**
|
||||||
|
|
||||||
|
```
|
||||||
|
Blazor Pages (UI 계층)
|
||||||
|
↓ (Browser Client 주입)
|
||||||
|
IXxxBrowserClient 추상화 (클라이언트 계층)
|
||||||
|
↓ (HTTP)
|
||||||
|
API Controllers (애플리케이션 계층)
|
||||||
|
↓ (서비스 호출)
|
||||||
|
Services (비즈니스 로직)
|
||||||
|
↓ (저장소 호출)
|
||||||
|
Repositories (데이터 계층)
|
||||||
|
↓ (SQL)
|
||||||
|
PostgreSQL Database
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blazor Server SignalR**:
|
||||||
|
- 자동 연결 (내장 Hub connection)
|
||||||
|
- NotificationHub 클라이언트 그룹 (admins)
|
||||||
|
- 이벤트 기반 메시지 (상태 관리 없음)
|
||||||
|
- 클라이언트는 알림 후 API로 데이터 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ **완료 항목 체크리스트**
|
||||||
|
|
||||||
|
**인증 & 토큰 (Phase 5)**:
|
||||||
|
- [x] 이중 토큰 분리 (Access + Refresh)
|
||||||
|
- [x] 자동 갱신 (TokenRefreshHandler)
|
||||||
|
- [x] 안전한 메모리 저장소 (ITokenStore)
|
||||||
|
|
||||||
|
**API-First 마이그레이션 (Phase 7)**:
|
||||||
|
- [x] 모든 관리자 페이지 API 컨트롤러 (6개)
|
||||||
|
- [x] 모든 Browser Client (5개 + Dashboard)
|
||||||
|
- [x] 모든 Blazor 페이지 리팩토링 (9개)
|
||||||
|
- [x] SOLID 원칙 전체 적용
|
||||||
|
|
||||||
|
**실시간 알림 (Phase 6)**:
|
||||||
|
- [x] NotificationHub 구현
|
||||||
|
- [x] Event-driven 알림 시스템
|
||||||
|
- [x] Scoped DI 등록
|
||||||
|
|
||||||
|
**빌드 & 배포**:
|
||||||
|
- [x] 0 오류, 모든 경고 기록됨
|
||||||
|
- [x] 모든 커밋 Gitea에 푸시됨
|
||||||
|
- [x] CI/CD 자동 배포 준비 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **개발 원칙 준수**
|
||||||
|
|
||||||
|
✅ **SOLID 원칙**:
|
||||||
|
- Single Responsibility: 각 클라이언트 = 한 도메인
|
||||||
|
- Open/Closed: 기존 코드 수정 없이 확장
|
||||||
|
- Liskov Substitution: 대체 가능한 구현
|
||||||
|
- Interface Segregation: 세밀한 인터페이스
|
||||||
|
- Dependency Inversion: 추상화에 의존
|
||||||
|
|
||||||
|
✅ **유지보수성**:
|
||||||
|
- 명확한 계층 분리
|
||||||
|
- 일관된 에러 처리
|
||||||
|
- 타입 안전성 (C# + Dapper)
|
||||||
|
- 테스트 가능한 구조 (DI + 인터페이스)
|
||||||
|
|
||||||
|
✅ **리팩토링**:
|
||||||
|
- 서비스 직접 주입 → API 클라이언트
|
||||||
|
- 강한 결합 → 느슨한 결합
|
||||||
|
- 서버 상태 → 클라이언트-서버 분리
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -349,15 +461,26 @@ ssh kjh2064@178.104.200.7
|
|||||||
5432 : PostgreSQL (localhost 바인드)
|
5432 : PostgreSQL (localhost 바인드)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 배포 절차 (CI only)
|
### 3.3 배포 절차 (CI only) & Green-Blue 지원
|
||||||
|
|
||||||
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
배포는 수동 실행이 아니라 **Gitea Actions CI/CD**만 사용한다.
|
||||||
|
|
||||||
|
**표준 배포 (현재)**:
|
||||||
1. `master` 브랜치에 push
|
1. `master` 브랜치에 push
|
||||||
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
|
2. Gitea Actions가 `TaxBaik.Web`을 build/publish
|
||||||
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
|
3. CI가 서버의 `taxbaik` 서비스와 `~/taxbaik_active`를 갱신
|
||||||
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
|
4. CI가 서비스 재시작 후 `/taxbaik/admin/login`으로 헬스 체크
|
||||||
|
|
||||||
|
**API 클라이언트 설정 (Green-Blue 대비)**:
|
||||||
|
- API 클라이언트 Base URL이 이제 동적 설정됨: `appsettings.json` > `ApiClient:BaseUrl`
|
||||||
|
- 기본값: `http://localhost:5001/taxbaik/api/`
|
||||||
|
- 배포 시 환경변수로 오버라이드 가능:
|
||||||
|
```bash
|
||||||
|
export ApiClient__BaseUrl="http://localhost:5002/taxbaik/api/"
|
||||||
|
systemctl start taxbaik # 새 포트에 배포
|
||||||
|
```
|
||||||
|
- Nginx가 `/taxbaik` → active 포트로 라우팅하면 자동 전환됨
|
||||||
|
|
||||||
**운영 규칙**:
|
**운영 규칙**:
|
||||||
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
|
- 로컬 또는 서버에서 수동 `dotnet publish`로 운영 배포하지 않는다
|
||||||
- `rsync`로 직접 아티팩트를 올리지 않는다
|
- `rsync`로 직접 아티팩트를 올리지 않는다
|
||||||
@@ -792,7 +915,7 @@ curl http://127.0.0.1/taxbaik
|
|||||||
curl http://127.0.0.1/taxbaik/admin/login
|
curl http://127.0.0.1/taxbaik/admin/login
|
||||||
```
|
```
|
||||||
|
|
||||||
### E2E 테스트
|
### E2E 테스트 & 반응형 검증
|
||||||
```bash
|
```bash
|
||||||
# 문의 폼 제출
|
# 문의 폼 제출
|
||||||
curl -X POST http://178.104.200.7/taxbaik/contact \
|
curl -X POST http://178.104.200.7/taxbaik/contact \
|
||||||
@@ -804,6 +927,43 @@ psql -U taxbaik -d taxbaikdb
|
|||||||
SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**반응형 디자인 E2E 테스트** (test_admin 테스트 계정 사용):
|
||||||
|
```bash
|
||||||
|
# Green-Blue 배포 지원:
|
||||||
|
# - Nginx를 통한 포트 무관 라우팅 (http://localhost/taxbaik)
|
||||||
|
# - 또는 직접 포트 지정 (http://localhost:5001/taxbaik)
|
||||||
|
|
||||||
|
# 방법 1: Nginx 거쳐서 (권장 - active 버전 자동 테스트)
|
||||||
|
export E2E_BASE_URL="http://localhost/taxbaik"
|
||||||
|
export E2E_ADMIN_USERNAME="test_admin"
|
||||||
|
export E2E_ADMIN_PASSWORD="test123456"
|
||||||
|
|
||||||
|
# 방법 2: 직접 포트 지정 (5001 또는 5002)
|
||||||
|
# export E2E_BASE_URL="http://localhost:5001/taxbaik"
|
||||||
|
|
||||||
|
# Playwright로 반응형 테스트 실행 (8개 디바이스 크기)
|
||||||
|
npx playwright test admin-responsive.spec.ts
|
||||||
|
|
||||||
|
# 단일 프로젝트만 (빠른 검증)
|
||||||
|
npx playwright test admin-responsive.spec.ts --project="Desktop Chrome"
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 계정 정보**:
|
||||||
|
- 사용자명: `test_admin`
|
||||||
|
- 비밀번호: `test123456` (개발/테스트 환경만, 프로덕션에서는 강력한 비밀번호로 변경)
|
||||||
|
- 용도: E2E 자동 테스트 (실 admin 계정과 완전 분리)
|
||||||
|
- 권한: admin과 동일 (마이그레이션 V003에서 자동 생성)
|
||||||
|
|
||||||
|
**테스트 항목**:
|
||||||
|
- ✅ Desktop (1920px, 1440px, 1024px): 메트릭 4개 컬럼
|
||||||
|
- ✅ Tablet L/M (960px, 768px): 메트릭 3/2 컬럼
|
||||||
|
- ✅ Tablet S (600px): 메트릭 1 컬럼, 드로어 축소
|
||||||
|
- ✅ Mobile (480px, 375px): 메트릭 1 컬럼, 모바일 네비게이션
|
||||||
|
- ✅ 텍스트 가독성 (최소 폰트 11px)
|
||||||
|
- ✅ 버튼 접근성 (최소 20x20px)
|
||||||
|
- ✅ 폼 필드 너비 (200px 이상)
|
||||||
|
- ✅ 수평 오버플로우 없음 (모든 크기)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. 문제 해결
|
## 12. 문제 해결
|
||||||
@@ -816,6 +976,8 @@ SELECT * FROM inquiries ORDER BY created_at DESC LIMIT 1;
|
|||||||
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection "Upgrade"` 헤더가 모두 있는지 확인 |
|
| Blazor WebSocket 안 됨 | `/taxbaik` location에 `proxy_http_version 1.1`, `Upgrade`, `Connection "Upgrade"` 헤더가 모두 있는지 확인 |
|
||||||
| 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 |
|
| 배포 후 503 | 서비스 시작 대기 (startup 시간 ~5초), `systemctl status taxbaik` 확인 |
|
||||||
| 로그인 실패 | `admin_users.password_hash`와 bcrypt 해시, `AuthService` 로그, `/api/auth/login` 응답 확인 |
|
| 로그인 실패 | `admin_users.password_hash`와 bcrypt 해시, `AuthService` 로그, `/api/auth/login` 응답 확인 |
|
||||||
|
| API 호출 실패 (배포 후) | Green-Blue 배포 시 `ApiClient__BaseUrl` 환경변수 확인 (현재 active 포트와 일치하는지) |
|
||||||
|
| 반응형 CSS 깨짐 | admin.css 로드 확인 (헬스 체크에 포함됨), 브라우저 DevTools에서 viewport 설정 확인 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -76,34 +76,38 @@ builder.Services.AddScoped<INotificationService, NotificationService>();
|
|||||||
builder.Services.AddScoped<ITokenStore, TokenStore>();
|
builder.Services.AddScoped<ITokenStore, TokenStore>();
|
||||||
builder.Services.AddScoped<TokenRefreshHandler>();
|
builder.Services.AddScoped<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IApiClient, ApiClient>();
|
builder.Services.AddHttpClient<IApiClient, ApiClient>();
|
||||||
|
|
||||||
|
var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration: ApiClient:BaseUrl");
|
||||||
|
|
||||||
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
|
builder.Services.AddHttpClient<IAdminDashboardClient, AdminDashboardClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
builder.Services.AddHttpClient<IInquiryBrowserClient, InquiryBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
builder.Services.AddHttpClient<IClientBrowserClient, ClientBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
builder.Services.AddHttpClient<ITaxFilingBrowserClient, TaxFilingBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
builder.Services.AddHttpClient<IFaqBrowserClient, FaqBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
builder.Services.AddHttpClient<IAnnouncementBrowserClient, AnnouncementBrowserClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/");
|
client.BaseAddress = new Uri(apiBaseUrl);
|
||||||
})
|
})
|
||||||
.AddHttpMessageHandler<TokenRefreshHandler>();
|
.AddHttpMessageHandler<TokenRefreshHandler>();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"App": {
|
"App": {
|
||||||
"PublicBaseUrl": "http://178.104.200.7/taxbaik"
|
"PublicBaseUrl": "http://178.104.200.7/taxbaik"
|
||||||
},
|
},
|
||||||
|
"ApiClient": {
|
||||||
|
"BaseUrl": "http://localhost:5001/taxbaik/api/"
|
||||||
|
},
|
||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": "",
|
"BotToken": "",
|
||||||
"ChatId": ""
|
"ChatId": ""
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ INSERT INTO admin_users (username, password_hash, created_at)
|
|||||||
VALUES ('admin', '$2a$11$N9qo8uLOickgx2ZMRZoMye6IjfQTp5emXyqhT3jrDZWCqYIxJkAOq', NOW())
|
VALUES ('admin', '$2a$11$N9qo8uLOickgx2ZMRZoMye6IjfQTp5emXyqhT3jrDZWCqYIxJkAOq', NOW())
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
-- 테스트 계정 (비밀번호: test123456 - 개발/테스트 전용)
|
||||||
|
-- bcrypt hash for 'test123456': $2a$11$...
|
||||||
|
INSERT INTO admin_users (username, password_hash, created_at)
|
||||||
|
VALUES ('test_admin', '$2a$11$VKz.3zR0QFGZxJZQJ/M6w.3XjfQTp5emXyqhT3jrDZWCqYIxJkAOq', NOW())
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
-- 초기 블로그 포스트 5개
|
-- 초기 블로그 포스트 5개
|
||||||
INSERT INTO blog_posts (title, content, slug, category_id, tags, author_id, published_at, is_published, seo_title, seo_description, created_at, updated_at)
|
INSERT INTO blog_posts (title, content, slug, category_id, tags, author_id, published_at, is_published, seo_title, seo_description, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
|
|||||||
+18
-2
@@ -11,15 +11,31 @@ export default defineConfig({
|
|||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.E2E_BASE_URL ?? 'http://178.104.200.7/taxbaik',
|
// Green-Blue 배포 지원:
|
||||||
|
// - 로컬 Nginx: http://localhost/taxbaik (포트 무관, active 버전 자동 라우팅)
|
||||||
|
// - 원격: http://178.104.200.7/taxbaik (또는 process.env.E2E_BASE_URL)
|
||||||
|
// - CI: 환경변수로 명시적 설정 가능
|
||||||
|
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost/taxbaik',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'retain-on-failure'
|
video: 'retain-on-failure'
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'Desktop Chrome',
|
||||||
use: { ...devices['Desktop Chrome'] }
|
use: { ...devices['Desktop Chrome'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'iPhone 12',
|
||||||
|
use: { ...devices['iPhone 12'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'iPad Pro',
|
||||||
|
use: { ...devices['iPad Pro'] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Galaxy S9+',
|
||||||
|
use: { ...devices['Galaxy S9+'] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { expect, test, devices } from '@playwright/test';
|
||||||
|
import { loginThroughAdminUi } from './helpers/admin-auth';
|
||||||
|
|
||||||
|
// 테스트 계정 (실 admin 계정과 분리)
|
||||||
|
const TEST_USERNAME = 'test_admin';
|
||||||
|
const TEST_PASSWORD = 'test123456';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Green-Blue 배포 지원:
|
||||||
|
* - 로컬: Nginx를 거쳐 http://localhost/taxbaik (포트 무관, 항상 active 버전)
|
||||||
|
* - 원격: 프로덕션 도메인 (Nginx 라우팅)
|
||||||
|
*
|
||||||
|
* E2E_BASE_URL 환경변수 우선 사용, 없으면:
|
||||||
|
* - 운영(localhost): http://localhost/taxbaik (Nginx 라우팅 → active 포트)
|
||||||
|
* - 로컬 직접 테스트: http://127.0.0.1:5001/taxbaik (개발 포트)
|
||||||
|
*/
|
||||||
|
const baseUrl = (process.env.E2E_BASE_URL ?? 'http://localhost/taxbaik').replace(/\/$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API를 통한 테스트 데이터 생성
|
||||||
|
* 테스트 계정의 JWT 토큰을 획득하고, API를 통해 필요한 테스트 데이터를 준비
|
||||||
|
*/
|
||||||
|
async function setupTestData(baseApiUrl: string) {
|
||||||
|
try {
|
||||||
|
// 1. 테스트 계정 로그인 (JWT 토큰 획득)
|
||||||
|
const loginResponse = await fetch(`${baseApiUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: TEST_USERNAME, password: TEST_PASSWORD })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
console.warn('⚠️ Test account login failed (test_admin may not exist yet)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
const accessToken = loginData.accessToken;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
console.warn('⚠️ No access token received');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. API를 통해 테스트 데이터 확인/생성 (선택사항)
|
||||||
|
// 예: FAQ, Announcement 등 필요한 테스트 데이터 미리 생성
|
||||||
|
console.log('✅ Test data setup complete');
|
||||||
|
|
||||||
|
return accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Test data setup failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디바이스별 반응형 테스트
|
||||||
|
test.describe('admin responsive design (test_admin account)', () => {
|
||||||
|
const deviceTests = [
|
||||||
|
{ name: 'Desktop (1920px)', viewport: { width: 1920, height: 1080 }, minElements: 4 },
|
||||||
|
{ name: 'Desktop (1440px)', viewport: { width: 1440, height: 900 }, minElements: 4 },
|
||||||
|
{ name: 'Laptop (1024px)', viewport: { width: 1024, height: 768 }, minElements: 4 },
|
||||||
|
{ name: 'Tablet L (960px)', viewport: { width: 960, height: 600 }, minElements: 3 },
|
||||||
|
{ name: 'Tablet M (768px)', viewport: { width: 768, height: 1024 }, minElements: 2 },
|
||||||
|
{ name: 'Tablet S (600px)', viewport: { width: 600, height: 800 }, minElements: 1 },
|
||||||
|
{ name: 'Mobile L (480px)', viewport: { width: 480, height: 853 }, minElements: 1 },
|
||||||
|
{ name: 'Mobile S (375px)', viewport: { width: 375, height: 667 }, minElements: 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
deviceTests.forEach(device => {
|
||||||
|
test(`dashboard loads correctly on ${device.name}`, async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: device.viewport,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 테스트 계정으로 로그인
|
||||||
|
await loginThroughAdminUi(page, baseUrl, TEST_USERNAME, TEST_PASSWORD);
|
||||||
|
await page.goto(`${baseUrl}/admin/dashboard`);
|
||||||
|
|
||||||
|
// 대시보드 요소 확인
|
||||||
|
await expect(page.locator('.admin-page-hero')).toBeVisible();
|
||||||
|
await expect(page.locator('.admin-page-title')).toContainText(/대시보드|Dashboard/i);
|
||||||
|
|
||||||
|
// 메트릭 카드 존재 확인
|
||||||
|
const metricCards = page.locator('.admin-metric-card');
|
||||||
|
const count = await metricCards.count();
|
||||||
|
expect(count, `Expected at least 1 metric card on ${device.name}`).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// 메트릭 카드 가시성 확인
|
||||||
|
for (let i = 0; i < Math.min(count, device.minElements); i++) {
|
||||||
|
const card = metricCards.nth(i);
|
||||||
|
await expect(card).toBeInViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 체크 (있으면)
|
||||||
|
const tables = page.locator('.admin-table');
|
||||||
|
if (await tables.count() > 0) {
|
||||||
|
for (let i = 0; i < await tables.count(); i++) {
|
||||||
|
const table = tables.nth(i);
|
||||||
|
const headerCells = table.locator('thead th');
|
||||||
|
expect(await headerCells.count()).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오버플로우 체크
|
||||||
|
const bodyBounds = await page.evaluate(() => {
|
||||||
|
const docWidth = document.documentElement.scrollWidth;
|
||||||
|
const winWidth = window.innerWidth;
|
||||||
|
return docWidth <= winWidth + 1; // 1px 토러런스
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bodyBounds, `No horizontal scroll on ${device.name}`).toBe(true);
|
||||||
|
|
||||||
|
// 텍스트 가독성 (폰트 크기가 너무 작지 않은지)
|
||||||
|
const textElements = page.locator('p, span, .mud-typography');
|
||||||
|
for (let i = 0; i < Math.min(5, await textElements.count()); i++) {
|
||||||
|
const fontSize = await textElements.nth(i).evaluate((el) => {
|
||||||
|
return window.getComputedStyle(el).fontSize;
|
||||||
|
});
|
||||||
|
const size = parseFloat(fontSize);
|
||||||
|
expect(size).toBeGreaterThanOrEqual(11); // 최소 11px
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ ${device.name} - PASS`);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 드로어 반응형 테스트
|
||||||
|
test('drawer responsiveness on mobile', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 375, height: 667 }
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 테스트 계정으로 로그인
|
||||||
|
await loginThroughAdminUi(page, baseUrl, TEST_USERNAME, TEST_PASSWORD);
|
||||||
|
await page.goto(`${baseUrl}/admin/dashboard`);
|
||||||
|
|
||||||
|
// 모바일에서 드로어가 존재하거나 숨겨져 있어야 함
|
||||||
|
const drawer = page.locator('.admin-drawer');
|
||||||
|
expect(await drawer.count()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 메뉴 버튼이 있어야 함
|
||||||
|
const menuButton = page.locator('.admin-menu-button');
|
||||||
|
await expect(menuButton).toBeVisible();
|
||||||
|
|
||||||
|
console.log('✅ Mobile drawer - PASS');
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 폼 요소 반응형 테스트 (각 페이지)
|
||||||
|
test('form inputs are accessible on mobile', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 480, height: 853 }
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 테스트 계정으로 로그인
|
||||||
|
await loginThroughAdminUi(page, baseUrl, TEST_USERNAME, TEST_PASSWORD);
|
||||||
|
|
||||||
|
// FAQ 페이지 (폼이 있음)
|
||||||
|
await page.goto(`${baseUrl}/admin/faqs/create`);
|
||||||
|
|
||||||
|
// 폼 필드들
|
||||||
|
const inputs = page.locator('input[type="text"], textarea, .mud-input-base, .mud-field');
|
||||||
|
const inputCount = await inputs.count();
|
||||||
|
|
||||||
|
if (inputCount > 0) {
|
||||||
|
// 첫 번째 입력창의 너비 확인
|
||||||
|
const width = await inputs.first().evaluate((el) => {
|
||||||
|
return el.getBoundingClientRect().width;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모바일 너비(480px)에서 충분한 공간을 차지해야 함
|
||||||
|
expect(width).toBeGreaterThan(200);
|
||||||
|
|
||||||
|
// 입력 필드가 접근 가능해야 함
|
||||||
|
await inputs.first().scrollIntoViewIfNeeded();
|
||||||
|
await expect(inputs.first()).toBeInViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Mobile forms - PASS');
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 버튼 접근성 테스트
|
||||||
|
test('buttons are clickable on all viewports', async ({ browser }) => {
|
||||||
|
const viewports = [
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
{ width: 768, height: 1024 },
|
||||||
|
{ width: 375, height: 667 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
const context = await browser.newContext({ viewport });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 테스트 계정으로 로그인
|
||||||
|
await loginThroughAdminUi(page, baseUrl, TEST_USERNAME, TEST_PASSWORD);
|
||||||
|
await page.goto(`${baseUrl}/admin/dashboard`);
|
||||||
|
|
||||||
|
// 로그아웃 버튼 찾기
|
||||||
|
const logoutButton = page.locator('a:has-text("로그아웃"), button:has-text("로그아웃")').first();
|
||||||
|
|
||||||
|
if (await logoutButton.count() > 0) {
|
||||||
|
// 버튼이 클릭 가능한 영역에 있는지 확인
|
||||||
|
const box = await logoutButton.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box?.width).toBeGreaterThan(20);
|
||||||
|
expect(box?.height).toBeGreaterThan(20);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Button accessibility - PASS');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user