From 0d07b2d26ae04e40b92885ed52741d791eb295ba Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 28 Jun 2026 11:28:22 +0900 Subject: [PATCH] fix: make API client base URL configurable for green-blue deployments Previously, all browser clients (AdminDashboardClient, InquiryBrowserClient, etc.) had hardcoded BaseAddress of http://localhost:5001/taxbaik/api/. This caused issues when implementing green-blue deployments where ports alternate between 5001/5002. Changes: - Add ApiClient:BaseUrl configuration in appsettings.json (default: 5001) - Update Program.cs to read configuration instead of hardcoding - All 6 browser clients now use dynamic configuration - Deployment script prepared for green-blue support (port can be injected via ApiClient__BaseUrl environment variable) Deployment Note: - For green-blue: Set ApiClient__BaseUrl environment variable before starting the service on the alternate port (5002) - Nginx still routes /taxbaik to the active instance - Supports zero-downtime deployments Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/deploy.yml | 2 +- TaxBaik.Web/Program.cs | 16 ++- TaxBaik.Web/appsettings.json | 3 + playwright.config.ts | 14 ++- tests/e2e/admin-responsive.spec.ts | 187 +++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/admin-responsive.spec.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7abed73..86f778d 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -105,7 +105,7 @@ jobs: scp -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes \ 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 \ -o ServerAliveInterval=10 \ "$DEPLOY_USER@$DEPLOY_HOST" bash << REMOTE diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 5f8d62d..5b1108a 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -76,34 +76,38 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); + +var apiBaseUrl = builder.Configuration["ApiClient:BaseUrl"] + ?? throw new InvalidOperationException("Missing configuration: ApiClient:BaseUrl"); + builder.Services.AddHttpClient(client => { - client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); + client.BaseAddress = new Uri(apiBaseUrl); }) .AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { - client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); + client.BaseAddress = new Uri(apiBaseUrl); }) .AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { - client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); + client.BaseAddress = new Uri(apiBaseUrl); }) .AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { - client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); + client.BaseAddress = new Uri(apiBaseUrl); }) .AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { - client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); + client.BaseAddress = new Uri(apiBaseUrl); }) .AddHttpMessageHandler(); builder.Services.AddHttpClient(client => { - client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); + client.BaseAddress = new Uri(apiBaseUrl); }) .AddHttpMessageHandler(); diff --git a/TaxBaik.Web/appsettings.json b/TaxBaik.Web/appsettings.json index 4da6028..2fe528a 100644 --- a/TaxBaik.Web/appsettings.json +++ b/TaxBaik.Web/appsettings.json @@ -14,6 +14,9 @@ "App": { "PublicBaseUrl": "http://178.104.200.7/taxbaik" }, + "ApiClient": { + "BaseUrl": "http://localhost:5001/taxbaik/api/" + }, "Telegram": { "BotToken": "", "ChatId": "" diff --git a/playwright.config.ts b/playwright.config.ts index 1d495b5..a57fc97 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,8 +18,20 @@ export default defineConfig({ }, projects: [ { - name: 'chromium', + name: '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+'] } } ] }); diff --git a/tests/e2e/admin-responsive.spec.ts b/tests/e2e/admin-responsive.spec.ts new file mode 100644 index 0000000..66f749f --- /dev/null +++ b/tests/e2e/admin-responsive.spec.ts @@ -0,0 +1,187 @@ +import { expect, test, devices } from '@playwright/test'; +import { loginThroughAdminUi } from './helpers/admin-auth'; + +const username = process.env.E2E_ADMIN_USERNAME ?? 'admin'; +const password = process.env.E2E_ADMIN_PASSWORD; +const baseUrl = (process.env.E2E_BASE_URL ?? 'http://localhost:5001/taxbaik').replace(/\/$/, ''); + +// 디바이스별 반응형 테스트 +test.describe('admin responsive design', () => { + 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 }) => { + test.skip(!password, 'E2E_ADMIN_PASSWORD is required.'); + + const context = await browser.newContext({ + viewport: device.viewport, + deviceScaleFactor: 1 + }); + const page = await context.newPage(); + + try { + await loginThroughAdminUi(page, baseUrl, username, 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 }) => { + test.skip(!password, 'E2E_ADMIN_PASSWORD is required.'); + + const context = await browser.newContext({ + viewport: { width: 375, height: 667 } + }); + const page = await context.newPage(); + + try { + await loginThroughAdminUi(page, baseUrl, username, 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 }) => { + test.skip(!password, 'E2E_ADMIN_PASSWORD is required.'); + + const context = await browser.newContext({ + viewport: { width: 480, height: 853 } + }); + const page = await context.newPage(); + + try { + await loginThroughAdminUi(page, baseUrl, username, 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 }) => { + test.skip(!password, 'E2E_ADMIN_PASSWORD is required.'); + + 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, username, 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'); + }); +});