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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+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": ""
|
||||||
|
|||||||
+13
-1
@@ -18,8 +18,20 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
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,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user