Files
Gnuboard7/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts
T
HeuJung face022073 feat(seo): 사이트맵 재생성 진행상황 + 실시간/폴링 UI
관리자 "전체 재생성"을 큐(Full 모드)로 던지고 진행상황을 SEO 탭에서 확인한다.
Reverb ON 이면 방송 채널 구독으로 실시간 갱신, OFF 면 상태 API 폴링으로 폴백한다.

- SitemapProgress 진행상황 스토어(queued→running→writing→completed/failed,
 캐시 TTL 3600, 방송 스로틀) + SitemapManager.getStatus 확장(progress + realtime_enabled)
- GET /api/admin/seo/sitemap/status(core.settings.read) 신규
- 관리자 재생성을 항상 Full 모드로 큐잉 + 진행률 기록, Job 최종 실패 시 progress=failed 고정
- core.admin.seo.sitemap 방송 채널 인증(core.settings.read)
- SEO 탭 진행상황 UI(상태 배지/단계/누적 URL/실시간·폴링 안내) + 폴링 분기
- 재생성 중 누적 URL 표기(replaceAllForContributor 삽입 건수 반환 — count 쿼리 없이 스트림 누적)
- ja 언어팩 진행상황 UI 번역 동기화
2026-07-18 18:22:16 +09:00

257 lines
11 KiB
TypeScript

/**
* E2E: 환경설정 > SEO/고급 — Sitemap 생성 설정 칸 (#481)
*
* @scenario admin_settings_sitemap_generation_fields
* @effects fields_mounted, fields_persisted, regenerate_is_async
*
* 배경: 대용량 사이트에서 sitemap 을 여러 파일로 나눠 생성하도록 분할 기준/압축 설정을
* 추가하고, 관리자 "지금 생성" 버튼을 즉시 실행에서 큐 예약으로 바꿨다. 설정 칸이 실제로
* 마운트·저장되는지, 재생성 버튼이 응답을 기다리며 멈추지 않는지 브라우저에서 확인한다.
*
* 검증:
* 1. SEO 탭에 분할 기준/압축 칸이 번역문과 함께 마운트된다
* 2. 분할 기준을 바꿔 저장하면 422 없이 성공하고 재진입 시 유지된다
* 3. 고급 탭에 Sitemap 캐시 기준값 칸이 마운트되고 기본값(86400)이 저장된다
* 4. "지금 생성" 클릭이 즉시 응답을 받는다 (동기 생성이면 대용량에서 타임아웃)
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const URLS_PER_FILE = '#field_sitemap_urls_per_file';
const GZIP_ROW = '#field_sitemap_gzip';
const HREFLANG_ROW = '#field_sitemap_hreflang_enabled';
const ADVANCED_SITEMAP_CACHE = '#cache_seo_sitemap';
/** 관리자 환경설정 SEO 탭 진입 */
async function gotoSeoTab(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/settings?tab=seo');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(URLS_PER_FILE)).toBeAttached({ timeout: 20_000 });
}
/** 관리자 환경설정 고급 탭 진입 */
async function gotoAdvancedTab(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/settings?tab=advanced');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator(ADVANCED_SITEMAP_CACHE)).toBeAttached({ timeout: 20_000 });
}
// @scenario tab=seo, permitted=yes
// @effects fields_mounted
test('@smoke #481 - SEO 탭에 Sitemap 분할 기준/압축 칸이 번역문과 함께 마운트된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoSeoTab(page);
expect(page.url()).not.toMatch(/\/admin\/login/);
const urlsPerFile = page.locator(URLS_PER_FILE);
await expect(urlsPerFile).toBeAttached();
// 다국어 키 미해석 회귀 가드
const urlsText = (await urlsPerFile.innerText()).trim();
expect(urlsText).not.toContain('$t:');
expect(urlsText.length).toBeGreaterThan(0);
// 프로토콜 상한이 입력 단계에서 그대로 반영된다
const input = urlsPerFile.locator('input[type="number"]').first();
await expect(input).toHaveAttribute('max', '50000');
await expect(input).toHaveAttribute('min', '1000');
const gzipRow = page.locator(GZIP_ROW);
await expect(gzipRow).toBeAttached();
expect((await gzipRow.innerText()).trim()).not.toContain('$t:');
await expect(gzipRow.locator('input[type="checkbox"]').first()).toBeAttached();
const hreflangRow = page.locator(HREFLANG_ROW);
await expect(hreflangRow).toBeAttached();
expect((await hreflangRow.innerText()).trim()).not.toContain('$t:');
await expect(hreflangRow.locator('input[type="checkbox"]').first()).toBeAttached();
});
// @scenario tab=seo, permitted=yes
// @effects fields_persisted
test('#481 - hreflang 토글을 바꿔 저장하면 성공하고 재진입 시 유지된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoSeoTab(page);
const toggle = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first();
await expect(toggle).toBeAttached({ timeout: 15_000 });
// 체크박스 input 은 sr-only(화면에서 숨김)이고 클릭 대상은 wrapper div(onClick) 이다.
// input 을 직접 클릭하면 toggle-switch-track 오버레이가 포인터 이벤트를 가로챈다.
const toggleSwitch = page.locator(`${HREFLANG_ROW} .toggle-switch-wrapper`).first();
const wasChecked = await toggle.isChecked();
// 토글을 반대 상태로 바꾼다
await toggleSwitch.click();
await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 });
const save = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
// 신규 boolean 키가 검증 규칙에 없으면 422 로 떨어진다 (배관 누락 회귀 가드)
expect((await save).status()).toBe(200);
await gotoSeoTab(page);
const reloaded = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first();
await expect.poll(() => reloaded.isChecked(), { timeout: 15_000 }).toBe(!wasChecked);
// 원상 복구 (E2E 는 실제 환경 설정을 건드린다)
await page.locator(`${HREFLANG_ROW} .toggle-switch-wrapper`).first().click();
const restore = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
expect((await restore).status()).toBe(200);
});
// @scenario tab=seo, permitted=yes
// @effects fields_persisted
test('#481 - 분할 기준을 바꿔 저장하면 성공하고 재진입 시 유지된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoSeoTab(page);
const input = page.locator(`${URLS_PER_FILE} input[type="number"]`).first();
// 폼이 저장된 설정으로 채워진 뒤에 수정해야 한다 (로드 전 입력은 다른 필드가 빈 채로 전송됨)
await expect.poll(() => input.inputValue(), { timeout: 15_000 }).not.toBe('');
const original = await input.inputValue();
const next = original === '30000' ? '40000' : '30000';
await input.fill(next);
await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 });
const save = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
// 신규 키가 검증 규칙에 없으면 422 로 떨어진다 (배관 누락 회귀 가드)
expect((await save).status()).toBe(200);
await gotoSeoTab(page);
const reloaded = page.locator(`${URLS_PER_FILE} input[type="number"]`).first();
await expect.poll(() => reloaded.inputValue(), { timeout: 15_000 }).toBe(next);
// 원상 복구 (E2E 는 실제 환경 설정을 건드린다)
await reloaded.fill(original);
const restore = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
expect((await restore).status()).toBe(200);
});
// @scenario tab=advanced, permitted=yes
// @effects fields_mounted, fields_persisted
test('#481 - 고급 탭 Sitemap 캐시 기준값 칸이 마운트되고 기본값이 저장된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoAdvancedTab(page);
const row = page.locator(ADVANCED_SITEMAP_CACHE);
await expect(row).toBeAttached();
expect((await row.innerText()).trim()).not.toContain('$t:');
const input = row.locator('input[type="number"]').first();
await expect(input).toBeAttached();
// 폼이 저장된 설정으로 채워진 뒤에 수정해야 한다 (로드 전 입력은 다른 필드가 빈 채로 전송됨)
await expect.poll(() => input.inputValue(), { timeout: 15_000 }).not.toBe('');
const original = await input.inputValue();
// 기본값 86400 은 형제 칸 규칙(최대 14400)을 재사용하면 저장 자체가 막힌다
const next = original === '86400' ? '43200' : '86400';
await input.fill(next);
await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 });
const save = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
expect((await save).status()).toBe(200);
// 원상 복구 (E2E 는 실제 환경 설정을 건드린다)
await gotoAdvancedTab(page);
const restored = page.locator(`${ADVANCED_SITEMAP_CACHE} input[type="number"]`).first();
await expect.poll(() => restored.inputValue(), { timeout: 15_000 }).toBe(next);
await restored.fill(original);
const restore = page.waitForResponse(
(r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST',
{ timeout: 20_000 }
);
await page.locator('#save_button').click();
expect((await restore).status()).toBe(200);
});
// @scenario tab=seo, permitted=yes
// @effects regenerate_is_async, progress_visible
test('#481 - "지금 생성" 클릭이 큐에 예약되고 진행상황이 화면에 표시된다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoSeoTab(page);
const regenerate = page.waitForResponse(
(r) => r.url().includes('/api/admin/seo/sitemap/regenerate'),
{ timeout: 20_000 }
);
const startedAt = Date.now();
await page.locator('#sitemap_last_updated_block button').first().click();
const response = await regenerate;
const elapsed = Date.now() - startedAt;
expect(response.status()).toBe(200);
// 예약 즉시 진행상황이 'queued' 로 실려 온다 (동기 생성이 아니라 큐 예약)
expect((await response.json())?.data?.progress?.status).toBe('queued');
// 동기 생성으로 되돌아가면 응답 시간이 데이터 규모에 비례해 늘어난다.
// 절대 하한 대신 "예약 응답이라면 당연히 만족하는" 상한으로 회귀만 잡는다.
expect(elapsed).toBeLessThan(10_000);
// 진행상황 블록이 화면에 나타나고 상태 배지가 미해석 키가 아닌 실제 문구로 렌더된다
const progressBlock = page.locator('#sitemap_progress_block');
await expect(progressBlock).toBeAttached({ timeout: 15_000 });
const progressText = (await progressBlock.innerText()).trim();
expect(progressText).not.toContain('$t:');
expect(progressText.length).toBeGreaterThan(0);
});
// @scenario tab=seo, permitted=yes
// @effects status_endpoint
test('#481 - SEO 탭 진입 시 sitemap 상태 API 가 진행상황/실시간 여부를 반환한다', async ({ page }) => {
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
const status = page.waitForResponse(
(r) => r.url().includes('/api/admin/seo/sitemap/status') && r.request().method() === 'GET',
{ timeout: 20_000 }
);
await gotoSeoTab(page);
const res = await status;
expect(res.status()).toBe(200);
const body = await res.json();
// 상태 응답 스키마: 진행상황(progress) + 실시간 연결 가능 여부(realtime_enabled)
expect(body?.data).toHaveProperty('realtime_enabled');
expect(body?.data).toHaveProperty('progress');
expect(body?.data).toHaveProperty('last_updated_at');
});