Repository navigation
Test Specs IV8
생성: 2026-10-15 · 기준: v07 설치 후 통합 검증(IV) · 원본: 여정별 계획서(Excel) ← Test Scenarios · IV 허브 · 기능 TC
Playwright(TypeScript) spec 소스입니다. 시나리오마다 ## <시나리오번호> 섹션이 있고 앵커는 #<시나리오번호 소문자> 입니다. 공용 헬퍼는 Test-Specs-Common. 비밀번호 등은 마스킹했습니다.
| 시나리오 | 파일 | 줄 | 해시 |
|---|---|---|---|
| IV8_S01 | tests/iv8/IV8_S01.spec.ts |
18 | 4ea3ecd6 |
| IV8_S02 | tests/iv8/IV8_S02.spec.ts |
15 | 8f0d208d |
| IV8_S03 | tests/iv8/IV8_S03.spec.ts |
29 | 68f09f08 |
| IV8_S04 | tests/iv8/IV8_S04.spec.ts |
70 | 0053808b |
| IV8_S05 | tests/iv8/IV8_S05.spec.ts |
77 | c0646226 |
| IV8_S06 | tests/iv8/IV8_S06.spec.ts |
67 | 7e508888 |
| IV8_S07 | tests/iv8/IV8_S07.spec.ts |
81 | 36d69f1e |
| IV8_S08 | tests/iv8/IV8_S08.spec.ts |
51 | 4f16b484 |
| IV8_S09 | tests/iv8/IV8_S09.spec.ts |
52 | c1b0949f |
| IV8_S10 | tests/iv8/IV8_S10.spec.ts |
48 | e1d80c9b |
| IV8_S11 | tests/iv8/IV8_S11.spec.ts |
37 | 7d592bd5 |
| IV8_S12 | tests/iv8/IV8_S12.spec.ts |
98 | 43e4bb43 |
| IV8_S13 | tests/iv8/IV8_S13.spec.ts |
40 | f76297ec |
| IV8_S14 | tests/iv8/IV8_S14.spec.ts |
59 | e3b90577 |
| IV8_S15 | tests/iv8/IV8_S15.spec.ts |
34 | aebfcf7f |
| IV8_S16 | tests/iv8/IV8_S16.spec.ts |
40 | a1b5437c |
| IV8_S17 | tests/iv8/IV8_S17.spec.ts |
126 | b125a4ea |
| IV8_S18 | tests/iv8/IV8_S18.spec.ts |
42 | 785837a0 |
| IV8_S19 | tests/iv8/IV8_S19.spec.ts |
61 | 71e29ab0 |
| IV8_S20 | tests/iv8/IV8_S20.spec.ts |
65 | 83d022b3 |
tests/iv8/IV8_S01.spec.ts · 18줄 · 해시 4ea3ecd6 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { loginAsAdmin } from '../../lib/ui';
// IV8_S01 : Data Migrations 진입 (iframe 핸드셰이크). 링크: /webconsole/_view/mcdatamigration
test('IV8_S01 Data Migrations 진입 (iframe 핸드셰이크)', async ({ page }) => {
const t0 = Date.now();
await loginAsAdmin(page);
test.info().annotations.push({ type: 'login-ms', description: String(Date.now() - t0) });
await page.getByText('Data Migrations', { exact: true }).first().click();
await page.getByRole('link', { name: 'Migration', exact: true }).first().click();
// stage 동시 부하 시 메뉴 클릭 후 15초 넘게 빈 화면이 유지된 적 있음(2026-10-02) → URL 전환 확인 후 iframe 을 넉넉히 대기
await page.waitForURL(/mcdatamigration/, { timeout: 60_000 });
await expect(page.locator('iframe').first()).toBeVisible({ timeout: 60_000 });
const frame = page.frameLocator('iframe').first();
await expect(frame.locator('#root, body').first()).toBeVisible({ timeout: 60_000 });
test.info().annotations.push({ type: 'iframe-src', description: (await page.locator('iframe').first().getAttribute('src')) ?? '' });
});tests/iv8/IV8_S02.spec.ts · 15줄 · 해시 8f0d208d · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { logout } from '../../lib/ui';
// IV8_S02 : 미로그인 상태에서 Data Migrations 직접 접근 (예상 실패)
test('IV8_S02 미로그인 상태에서 Data Migrations 직접 접근 (예상실패)', async ({ page }) => {
await logout(page);
const res = await page.goto('/webconsole/_view/mcdatamigration');
test.info().annotations.push({ type: 'landed', description: `${res?.status()} ${page.url()}` });
// 기대: 로그인/Unauthorized 화면으로 이동하고 콘텐츠(iframe) 미노출.
// 2026-10-01 Playwright 실행: 1회차 /auth/unauthorized#cookieError, 이후 3회 '502 Bad Gateway'(같은 URL) → 현재 이 단언은 실패한다(제품 확인 필요).
await expect(page).toHaveURL(/\/auth\/(login|unauthorized)/);
await expect(page.getByRole('heading', { name: 'Unauthorized' }).or(page.getByPlaceholder('Your ID'))).toBeVisible();
await expect(page.locator('iframe')).toHaveCount(0);
});tests/iv8/IV8_S03.spec.ts · 29줄 · 해시 68f09f08 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, ensureBucket, bucketName, dmBlocker, dmRegion } from '../../lib/iv8-os';
// IV8_S03 : Object Storage: Bucket 생성 → Bucket List 표시 [정상, CSP별]
// Data Migrations > Generate > Object Storage → Target credential 선택 → + Bucket(mcmp-e2e-v07-os{n}) → Bucket List·Target Bucket 드롭다운 확인
// 재실행 가능: 이미 있으면 생성은 건너뛰고 목록 표시만 확인한다.
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) {
test(`${tid('IV8_S03', c)} Object Storage: Bucket 생성 → Bucket List 표시`, async ({ page }) => {
const why = dmBlocker(c);
test.skip(!!why, why);
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openDm(page, 'Generate');
await selectCred(page, f, c);
api();
const name = bucketName(c);
const row = await ensureBucket(page, f, name, api);
expect(row, 'Bucket List 행').toContain(name);
// 목록에 올라와도 Status 가 Failed 면 CSP 생성 실패(2026-10-12 KT: MCMP 에 Failed 로 등록, cspResourceId 빈 값)
expect(row, 'Bucket Status').not.toMatch(/\bFailed\b/i);
// Region 지정 CSP 는 지정 리전에 생성됐는지 확인(2026-10-12: 자동 선택 af-south-1 로 생성된 이력)
if (dmRegion(c)) expect(row, 'Bucket Region').toContain(dmRegion(c));
// Target Bucket 드롭다운에도 표시
await expect.poll(() => f.locator('select#newBucket option').allTextContents().then((o) => o.join(',')), { timeout: 30_000 }).toContain(name);
});
}tests/iv8/IV8_S04.spec.ts · 70줄 · 해시 0053808b · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, selectBucket, bucketName, bucketRowText, readObjects, logHistory, dmBlocker, dmRegion } from '../../lib/iv8-os';
// IV8_S04 : Object Storage: 샘플 데이터 생성 (Generate) [정상, CSP별]
// Generate > Object Storage → Target credential·Region → Target Bucket(mcmp-e2e-v07-os{n}, IV8_S03) → TXT 만 1GB → Submit
// → 결과(Result)·API 응답, bucket Objects, Log Management(Object Storage / Generate) 확인
const TYPES = ['SQL', 'CSV', 'TXT', 'PNG', 'GIF', 'ZIP', 'JSON', 'XML', 'ServerJSON', 'ServerSQL'];
const SIZE_GB = '1'; // 기본 10GB → 1GB(시간·전송 비용)
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) {
test(`${tid('IV8_S04', c)} Object Storage: 샘플 데이터 생성 (Generate)`, async ({ page }) => {
test.setTimeout(45 * 60_000);
const why = dmBlocker(c);
test.skip(!!why, why);
const api = apiRecorder(page);
await loginAsAdmin(page);
let f = await openDm(page, 'Generate');
await selectCred(page, f, c);
const name = bucketName(c);
const row = await bucketRowText(f, name);
test.skip(!row || /Failed/i.test(row), `선행 IV8_S03_${c.code} bucket ${name} 없음/실패: ${row || '(없음)'}`);
if (dmRegion(c)) expect(row, 'bucket Region').toContain(dmRegion(c));
await selectBucket(f, name);
// TXT 만 체크, 크기 1GB
for (const t of TYPES) {
const cb = f.locator(`#check${t}`);
if (await cb.count()) await cb.setChecked(t === 'TXT', { force: true });
}
await f.locator('#sizeTXT').fill(SIZE_GB);
const checked = await f.evaluate((ts) => ts.filter((t) => (document.getElementById(`check${t}`) as HTMLInputElement | null)?.checked), TYPES);
test.info().annotations.push({ type: 'generate-input', description: `bucket=${name}, checked=${checked.join(',')}, TXT=${SIZE_GB}GB` });
expect(checked).toEqual(['TXT']);
api();
let genResp = '';
page.on('response', async (r) => {
if (/:3300\/generate\//i.test(r.url()) && r.request().method() !== 'GET') genResp = `${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')}`;
});
const t0 = Date.now();
await f.locator('#submitBtn').click();
// 완료 판단: generate API(POST) 응답 또는 Result 영역의 완료/실패 문구. 1GB 생성·업로드는 수 분 걸릴 수 있음
const result = f.locator('#resultText');
await expect.poll(async () => {
const v = await result.inputValue().catch(() => '');
return genResp || /(success|fail|error|complete|finished|done)/i.test(v) ? 'done' : '';
}, { timeout: 40 * 60_000, intervals: [5000, 10_000, 30_000] }).toBe('done');
await page.waitForTimeout(3000); // 응답 후 Result 갱신
test.info().annotations.push({ type: 'generate-response', description: genResp || '(POST 응답 미확인, Result 문구로 판단)' });
const text = (await result.inputValue()).trim();
test.info().annotations.push({ type: 'generate-sec', description: String(Math.round((Date.now() - t0) / 1000)) });
test.info().annotations.push({ type: 'generate-result', description: text.slice(-1200) });
test.info().annotations.push({ type: 'generate-api', description: api().slice(0, 1500) || '(API 기록 없음)' });
expect(text, 'Generate 결과').not.toMatch(/\b(fail(ed)?|error)\b/i);
// bucket 객체 확인
f = await openDm(page, 'Generate');
await selectCred(page, f, c);
const objs = await readObjects(page, f, name);
if (objs) expect(objs.length, 'bucket 객체 수').toBeGreaterThan(0);
// Log Management 이력
const logs = await logHistory(page, 'Object Storage', 'Generate');
// 최신 이력(첫 행)이 이 bucket 의 generate success 여야 한다
expect(logs[0] ?? '', 'Log Management 최신 Generate 이력').toMatch(new RegExp(`generate .*${dmRegion(c) ? dmRegion(c) + ' ' : ''}${name} .*success`, 'i'));
});
}tests/iv8/IV8_S05.spec.ts · 77줄 · 해시 c0646226 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, CSPS } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import {
apiRecorder, openDm, selectCred, selectBucket, ensureBucket, bucketName, bucketRowText, readObjects, logHistory,
nextStep, diagnose, dmBlocker, dmRegion,
} from '../../lib/iv8-os';
// IV8_S05 : Object Storage 마이그레이션 (Migration) [정상, CSP별]
// Migration > Object Storage 4단계: 1 Source(해당 CSP, mcmp-e2e-v07-os{n}) → 2 Diagnose → 3 Target(AWS os1m, AWS 행은 GCP os4m. 없으면 + Bucket) → 4 Diagnose → Submit
// → Result·API, 대상 bucket 객체 수 = 원본 Diagnose Object Count, Log Management(Migrate) 확인
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) {
test(`${tid('IV8_S05', c)} Object Storage 마이그레이션 (Migration)`, async ({ page }) => {
test.setTimeout(60 * 60_000);
const why = dmBlocker(c);
test.skip(!!why, why);
const tc = CSPS.find((x) => x.code === (c.code === 'AWS' ? 'GCP' : 'AWS'))!; // 대상 CSP
const src = bucketName(c);
const dst = bucketName(tc, 'm');
test.info().annotations.push({ type: 'route', description: `${c.code} ${src} → ${tc.code} ${dst}` });
const api = apiRecorder(page);
await loginAsAdmin(page);
let f = await openDm(page, 'Migration');
// 1. Source
await selectCred(page, f, c, 'sourceCredentialSelect');
const srow = await bucketRowText(f, src);
test.skip(!srow || /Failed/i.test(srow), `선행 IV8_S03/S04_${c.code} 원본 bucket ${src} 없음/실패: ${srow || '(없음)'}`);
await selectBucket(f, src, 'sourceBucket');
await nextStep(page, f, '#collapseOne');
// 2. Diagnose Source
const sd = await diagnose(page, f, 'src');
expect(Number(sd.count), '원본 객체 수(IV8_S04 선행)').toBeGreaterThan(0);
await nextStep(page, f, '#collapseTwo');
// 3. Target (없으면 + Bucket 으로 생성)
await selectCred(page, f, tc, 'targetCredentialSelect');
api();
const trow = await ensureBucket(page, f, dst, api, '#tgt-bkt-createBtn');
expect(trow, '대상 bucket').not.toMatch(/\bFailed\b/i);
if (dmRegion(tc)) expect(trow, '대상 bucket Region').toContain(dmRegion(tc));
await selectBucket(f, dst, 'targetBucket');
await nextStep(page, f, '#collapseThree');
// 4. Diagnose Target (실행 전)
const td = await diagnose(page, f, 'tgt');
// Submit
api();
let migResp = '';
page.on('response', (r) => { if (/:3300\/migrat/i.test(r.url()) && r.request().method() !== 'GET') migResp = `${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')}`; });
const t0 = Date.now();
await f.locator('#submitBtn').click();
const result = f.locator('#resultText');
await expect.poll(async () => {
const v = await result.inputValue().catch(() => '');
return migResp || /(success|fail|error|complete|finished)/i.test(v) ? 'done' : '';
}, { timeout: 55 * 60_000, intervals: [5000, 10_000, 30_000] }).toBe('done');
await page.waitForTimeout(3000);
const text = (await result.inputValue()).trim();
test.info().annotations.push({ type: 'migrate-sec', description: String(Math.round((Date.now() - t0) / 1000)) });
test.info().annotations.push({ type: 'migrate-response', description: migResp || '(POST 응답 미확인)' });
test.info().annotations.push({ type: 'migrate-result', description: text.slice(-1500) });
test.info().annotations.push({ type: 'migrate-api', description: api().slice(0, 1500) || '(API 기록 없음)' });
expect(text, 'Migration 결과').not.toMatch(/\b(fail(ed)?|error)\b/i);
// 대상 bucket 객체 확인 (Generate 화면 Browse objects)
f = await openDm(page, 'Generate');
await selectCred(page, f, tc);
const objs = await readObjects(page, f, dst);
if (objs) expect(objs.length, '대상 bucket 객체(폴더) 수').toBeGreaterThan(0);
test.info().annotations.push({ type: 'count-compare', description: `원본 Diagnose count=${sd.count}, 대상 Diagnose(실행 전) count=${td.count}` });
// Log Management 최신 Migrate 이력
const logs = await logHistory(page, 'Object Storage', 'Migrate');
expect(logs[0] ?? '', 'Log Management 최신 Migrate 이력').toMatch(/migrat.*success/i);
});
}tests/iv8/IV8_S06.spec.ts · 67줄 · 해시 7e508888 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import {
apiRecorder, openDm, selectCred, selectBucket, bucketName, bucketRowText, backupName, backupRowText, logHistory, dmBlocker, dmRegion,
} from '../../lib/iv8-os';
// IV8_S06 : Object Storage 백업 (Back Up) [정상, CSP별]
// Back Up > Object Storage → Source credential·Region·Bucket(mcmp-e2e-v07-os{n}) → Backup Name(mcmp-e2e-v07-bk{n}) → Submit
// → Result·API, Backup List(Back Up·Restore 화면) 표시, Log Management(Backup) 확인
// 재실행 가능: 같은 이름의 백업이 이미 있으면 생성은 건너뛰고 목록 표시만 확인한다.
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) {
test(`${tid('IV8_S06', c)} Object Storage 백업 (Back Up)`, async ({ page }) => {
test.setTimeout(60 * 60_000);
const why = dmBlocker(c);
test.skip(!!why, why);
const src = bucketName(c);
const bk = backupName(c);
const api = apiRecorder(page);
await loginAsAdmin(page);
let f = await openDm(page, 'Back Up');
await selectCred(page, f, c, 'sourceCredentialSelect');
const srow = await bucketRowText(f, src);
test.skip(!srow || /Failed/i.test(srow), `선행 IV8_S03/S04_${c.code} 원본 bucket ${src} 없음/실패: ${srow || '(없음)'}`);
if (dmRegion(c)) expect(srow, '원본 bucket Region').toContain(dmRegion(c));
const before = await backupRowText(f, bk);
if (before) {
test.info().annotations.push({ type: 'backup-exists', description: `${bk} 이미 있음 → 생성 건너뜀: ${before}` });
} else {
await selectBucket(f, src, 'sourceBucket');
await f.locator('#backup-name').fill(bk);
api();
let bkResp = '';
page.on('response', (r) => { if (/:3300\/backup/i.test(r.url()) && r.request().method() === 'POST') bkResp = `${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')}`; });
const t0 = Date.now();
await f.locator('#submitBtn').click();
const result = f.locator('#resultText');
await expect.poll(async () => {
const v = await result.inputValue().catch(() => '');
return bkResp || /(success|fail|error|complete|finished)/i.test(v) ? 'done' : '';
}, { timeout: 55 * 60_000, intervals: [5000, 10_000, 30_000] }).toBe('done');
await page.waitForTimeout(3000);
const text = (await result.inputValue()).trim();
test.info().annotations.push({ type: 'backup-sec', description: String(Math.round((Date.now() - t0) / 1000)) });
test.info().annotations.push({ type: 'backup-response', description: bkResp || '(POST 응답 미확인)' });
test.info().annotations.push({ type: 'backup-result', description: text.slice(-1500) });
test.info().annotations.push({ type: 'backup-api', description: api().slice(0, 1500) || '(API 기록 없음)' });
expect(text, 'Back Up 결과').not.toMatch(/\b(fail(ed)?|error)\b/i);
}
// Backup List: Back Up 화면(새로 열기) / Restore 화면
f = await openDm(page, 'Back Up');
await expect.poll(() => backupRowText(f, bk), { timeout: 60_000 }).not.toBe('');
const r1 = await backupRowText(f, bk);
test.info().annotations.push({ type: 'backup-list(backup)', description: r1 });
expect(r1, '백업 상태').not.toMatch(/\bFailed\b/i);
f = await openDm(page, 'Restore');
await expect.poll(() => backupRowText(f, bk), { timeout: 60_000 }).not.toBe('');
test.info().annotations.push({ type: 'backup-list(restore)', description: await backupRowText(f, bk) });
// Log Management 최신 Backup 이력
const logs = await logHistory(page, 'Object Storage', 'Backup');
expect(logs[0] ?? '', 'Log Management 최신 Backup 이력').toMatch(/backup.*success/i);
});
}tests/iv8/IV8_S07.spec.ts · 81줄 · 해시 36d69f1e · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import {
apiRecorder, openDm, selectCred, selectBucket, ensureBucket, bucketName, backupName, backupRowText, readObjects, listFolder, logHistory, dmBlocker, dmRegion,
} from '../../lib/iv8-os';
// IV8_S07 : Object Storage 복구 (Restore) [정상, CSP별]
// Restore > Object Storage → Source Backup List 에서 mcmp-e2e-v07-bk{n}(IV8_S06) 행 선택 → Target credential·Region → + Bucket mcmp-e2e-v07-os{n}r → Submit
// → Result·API, 대상 bucket 객체(원본 os{n} 과 같은 datamold 폴더), Log Management(Restore) 확인
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) {
test(`${tid('IV8_S07', c)} Object Storage 복구 (Restore)`, async ({ page }) => {
test.setTimeout(60 * 60_000);
const why = dmBlocker(c);
test.skip(!!why, why);
const bk = backupName(c);
const dst = bucketName(c, 'r');
const api = apiRecorder(page);
await loginAsAdmin(page);
let f = await openDm(page, 'Restore');
// Source: Backup List 행 선택
await expect.poll(() => backupRowText(f, bk), { timeout: 60_000 }).not.toBe('');
const brow = await backupRowText(f, bk);
test.skip(!/completed/i.test(brow), `선행 IV8_S06_${c.code} 백업 ${bk} 없음/미완료: ${brow}`);
const row = f.locator(`tr.backup-row[data-name="${bk}"]`).first();
await row.locator('td', { hasText: bk }).first().click();
await page.waitForTimeout(1500);
const sel = await row.evaluate((r) => ({ cls: r.className, checked: (r.querySelector('input[type=checkbox]') as HTMLInputElement | null)?.checked ?? null, id: (r as HTMLElement).dataset.id ?? '' }));
const label = await f.locator('#sourcePointLabel').inputValue().catch(() => '');
test.info().annotations.push({ type: 'backup-select', description: `${brow} | row class=${sel.cls} checked=${sel.checked} | Source label=${label}` });
// Target: credential·Region → + Bucket(res) → Target Bucket
await selectCred(page, f, c, 'targetCredentialSelect');
api();
const trow = await ensureBucket(page, f, dst, api, '#res-bkt-createBtn');
expect(trow, '대상 bucket').not.toMatch(/\bFailed\b/i);
if (dmRegion(c)) expect(trow, '대상 bucket Region').toContain(dmRegion(c));
await selectBucket(f, dst, 'targetBucket');
// Submit
api();
let rsResp = '';
page.on('response', (r) => { if (/:3300\/restore/i.test(r.url()) && r.request().method() === 'POST') rsResp = `${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, '')}`; });
const t0 = Date.now();
await f.locator('#submitBtn').click();
const result = f.locator('#resultText');
await expect.poll(async () => {
const v = await result.inputValue().catch(() => '');
return rsResp || /(success|fail|error|complete|finished)/i.test(v) ? 'done' : '';
}, { timeout: 55 * 60_000, intervals: [5000, 10_000, 30_000] }).toBe('done');
await page.waitForTimeout(3000);
const text = (await result.inputValue()).trim();
test.info().annotations.push({ type: 'restore-sec', description: String(Math.round((Date.now() - t0) / 1000)) });
test.info().annotations.push({ type: 'restore-response', description: rsResp || '(POST 응답 미확인)' });
test.info().annotations.push({ type: 'restore-result', description: text.slice(-1500) });
test.info().annotations.push({ type: 'restore-api', description: api().slice(0, 1500) || '(API 기록 없음)' });
expect(text, 'Restore 결과').not.toMatch(/\b(fail(ed)?|error)\b/i);
// 대상 bucket 객체: 원본(os{n})과 같은 datamold 폴더가 있어야 한다.
// 2026-10-12 AWS 확인: 복구 데이터는 원래 경로가 아니라 '{백업 id}/' 접두 폴더 아래에 원본 구조 그대로 들어간다 → 접두 폴더가 있으면 그 안을 비교
f = await openDm(page, 'Generate');
await selectCred(page, f, c);
const srcObjs = await readObjects(page, f, bucketName(c));
let dstObjs = await readObjects(page, f, dst);
const top = (dstObjs ?? []).map((t) => t.split(' ')[0]);
if (sel.id && top.some((k) => k.startsWith(sel.id))) {
test.info().annotations.push({ type: 'restore-prefix', description: `복구 위치: ${dst}/${sel.id}/ (원래 경로 아님)` });
dstObjs = await listFolder(page, f, dst, sel.id);
}
const folders = (o: string[] | null) => (o ?? []).filter((t) => /datamold/.test(t)).sort();
test.info().annotations.push({ type: 'compare', description: `원본 ${folders(srcObjs).join(', ')} / 복구 ${folders(dstObjs).join(', ')}` });
expect(folders(dstObjs), '복구된 datamold 폴더(이름·항목 수) = 원본').toEqual(folders(srcObjs));
expect(folders(dstObjs).length, '복구된 폴더 수').toBeGreaterThan(0);
// Log Management 최신 Restore 이력
const logs = await logHistory(page, 'Object Storage', 'Restore');
expect(logs[0] ?? '', 'Log Management 최신 Restore 이력').toMatch(/restore.*success/i);
});
}tests/iv8/IV8_S08.spec.ts · 51줄 · 해시 4f16b484 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid, CSPS } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, deleteBucket, deleteBackup, bucketName, backupName, bucketRowText, backupRowText, dmBlocker } from '../../lib/iv8-os';
// IV8_S08 : Object Storage: 테스트 자원 정리 — Bucket·백업 삭제 [정상, CSP별]
// 삭제 대상(이 행이 S03~S07 에서 만든 것):
// ① 자기 CSP bucket mcmp-e2e-v07-os{n}(S03), os{n}r(S07)
// ② S05 Migration 대상 bucket: AWS 행 → GCP os4m, 다른 행 → AWS os1m (2026-10-12 사용자 지시로 정리 범위 확장)
// ③ S06 백업 mcmp-e2e-v07-bk{n} (Restore 화면 Backup List)
// Generate > Object Storage 의 Bucket List 에서 체크 → Delete → 이름 입력 확인. 없는 자원은 건너뛴다(최소 1개는 삭제돼야 PASS).
for (const c of activeCsps(['AWS', 'Azure', 'Alibaba', 'GCP', 'Tencent', 'NHN Cloud', 'NCP', 'IBM', 'KT'])) {
test(`${tid('IV8_S08', c)} Object Storage: Bucket 삭제 (테스트 자원 정리)`, async ({ page }) => {
const why = dmBlocker(c);
test.skip(!!why, why);
const tc = CSPS.find((x) => x.code === (c.code === 'AWS' ? 'GCP' : 'AWS'))!; // S05 대상 CSP
const api = apiRecorder(page);
await loginAsAdmin(page);
const deleted: string[] = [];
// ③ 백업 먼저(원본 bucket 정보를 참조하므로 bucket 보다 먼저 지운다)
let f = await openDm(page, 'Restore');
await page.waitForTimeout(3000);
const bk = backupName(c);
if (await deleteBackup(page, f, bk, api)) deleted.push(`backup ${bk}`);
// ① 자기 CSP bucket
f = await openDm(page, 'Generate');
await selectCred(page, f, c);
api();
for (const n of [bucketName(c), bucketName(c, 'r')]) if (await deleteBucket(page, f, n, api)) deleted.push(`${c.code} ${n}`);
// ② S05 대상 bucket(다른 CSP)
f = await openDm(page, 'Generate');
await selectCred(page, f, tc);
api();
const mig = bucketName(tc, 'm');
if (await deleteBucket(page, f, mig, api)) deleted.push(`${tc.code} ${mig}`);
test.info().annotations.push({ type: 'deleted', description: deleted.join(', ') || '(없음)' });
expect(deleted.length, '삭제한 자원 수(하나도 없으면 IV8_S03~S07 선행 필요)').toBeGreaterThan(0);
expect(await bucketRowText(f, mig), `${mig} 목록에서 제거`).toBe('');
// 최종 확인: 남은 자원 없음
f = await openDm(page, 'Generate');
await selectCred(page, f, c);
for (const n of [bucketName(c), bucketName(c, 'r')]) expect(await bucketRowText(f, n), `${n} 목록에서 제거`).toBe('');
f = await openDm(page, 'Restore');
await page.waitForTimeout(3000);
expect(await backupRowText(f, bk), `${bk} Backup List 에서 제거`).toBe('');
});
}tests/iv8/IV8_S09.spec.ts · 52줄 · 해시 c1b0949f · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder } from '../../lib/iv8-os';
import { openSql, ensureRdb, rdbsOf, refreshRow, PENDING, RDB_CSPS } from '../../lib/iv8-rdb';
// IV8_S09 : SQL Database: RDBMS 인스턴스 생성 → Instance List 표시 [정상, CSP별 AWS·GCP] (TC-DATA-018)
// Data Migrations > Generate > SQL Database 탭 → credential 선택(Region 지정) → + Instance → 엔진/버전/Class/ID/계정 입력 → Create → Instance List 에 표시
// 입력값은 Drive mc-data-manager 시트 기준(2026-10-14): AWS mysql 8.4.11 / db.m5.large / mcmp — rdb1aws, rdb3aws 2개.
// GCP MYSQL 8_4 / db-g1-small / root(화면에서 root 만 허용) — rdb2gpc. 재실행 가능(이미 있으면 생성 생략).
// CSP 의 인스턴스를 모두 생성 요청한 뒤 함께 완료를 기다린다(AWS 1개 약 12분).
// 2026-10-15: 대상표 RDB = AWS·GCP·Alibaba·NCP (Alibaba·NCP 최소 사양, 사용자 지시)
for (const c of activeCsps(RDB_CSPS)) {
test(`${tid('IV8_S09', c)} SQL Database: RDBMS 인스턴스 생성 → Instance List 표시`, async ({ page }) => {
test.setTimeout(50 * 60_000);
const list = rdbsOf(c);
const api = apiRecorder(page);
await loginAsAdmin(page);
let f = await openSql(page, c);
api();
for (const r of list) {
// Alibaba API 호출 제한("user flow control")으로 Class 목록이 비는 경우가 있어 화면을 다시 열고 최대 3회 시도
let row = '';
for (let i = 1; ; i++) {
try { row = await ensureRdb(page, f, c, r, api); break; } catch (e) {
if (i >= 3) throw e;
test.info().annotations.push({ type: 'rdb-retry', description: `${r.id} ${i}회차 실패 → 60s 후 재시도: ${String(e).slice(0, 300)}` });
await page.waitForTimeout(60_000);
f = await openSql(page, c);
}
}
expect(row, `${r.id} Instance List 행`).toContain(r.id);
expect(row, `${r.id} Instance Class 표시`).toContain(r.classText);
f = await openSql(page, c); // 생성 패널을 닫고 목록을 새로 읽는다
}
// 생성 진행 상태(creating 등)가 끝날 때까지 갱신하며 대기(최대 30분)
const cur: Record<string, string> = {};
const t0 = Date.now();
const pending = () => list.filter((r) => !cur[r.id] || PENDING.test(cur[r.id])); // 빈 값(목록 재조회 중)은 완료로 보지 않는다
while (pending().length && Date.now() - t0 < 30 * 60_000) {
await page.waitForTimeout(30_000);
for (const r of pending()) cur[r.id] = (await refreshRow(page, c, r.id)).text || cur[r.id] || '';
}
for (const r of list) {
test.info().annotations.push({ type: 'rdb-final', description: `${r.id} — ${Math.round((Date.now() - t0) / 1000)}s 대기 후: ${cur[r.id]}` });
expect(cur[r.id], `${r.id} 최종 Instance List 행`).toContain(r.id);
expect(cur[r.id], `${r.id} 인스턴스 상태`).not.toMatch(/fail|error/i);
expect(cur[r.id], `${r.id} 생성 완료 상태(creating 종료)`).not.toMatch(PENDING);
}
});
}tests/iv8/IV8_S10.spec.ts · 48줄 · 해시 e1d80c9b · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, deleteBackup, backupRowText } from '../../lib/iv8-os';
import { openSql, deleteRdb, rdbsOf, refreshRow, rdbFlowOf, RDB_CSPS } from '../../lib/iv8-rdb';
// IV8_S10 : SQL Database: RDBMS 인스턴스 삭제 (테스트 자원 정리) [정상, CSP별 AWS·GCP] (TC-DATA-019)
// 1.Restore > SQL Database → Backup List 에서 'mcmp-e2e-v07-bk2'(IV8_S13) 체크 → Delete → 확인 팝업에 백업 이름 입력 → Delete (AWS 실행에서 1회)
// 2.Generate > SQL Database → credential 선택 → Instance List 에서 S09 인스턴스 체크 → Delete → 확인 팝업에 인스턴스 이름 입력 → Delete
// 3.Instance List·Backup List 에서 사라짐 확인. 대상: AWS rdb1aws·rdb3aws / GCP rdb2gpc (Drive mc-data-manager 시트 기준, 2026-10-14)
// 2026-10-15: 대상표 RDB = AWS·GCP·Alibaba·NCP, 백업은 CSP 별(rdbFlowOf(c).backup)
for (const c of activeCsps(RDB_CSPS)) {
test(`${tid('IV8_S10', c)} SQL Database: RDBMS 인스턴스 삭제 (테스트 자원 정리)`, async ({ page }) => {
test.setTimeout(40 * 60_000);
const list = rdbsOf(c);
const api = apiRecorder(page);
await loginAsAdmin(page);
// AWS 는 2026-10-14 흐름의 옛 백업(mcmp-e2e-v07-bk2)도 남아 있으면 함께 정리
for (const RDB_BACKUP of [rdbFlowOf(c).backup, ...(c.code === 'AWS' ? ['mcmp-e2e-v07-bk2'] : [])]) {
const r = await openDm(page, 'Restore');
await r.getByText(/SQL Database/i).first().click();
await page.waitForTimeout(3000);
api();
if (await backupRowText(r, RDB_BACKUP)) {
expect(await deleteBackup(page, r, RDB_BACKUP, api), `${RDB_BACKUP} 백업 삭제`).toBe(true);
} else test.info().annotations.push({ type: 'backup-missing', description: `${RDB_BACKUP} Backup List 에 없음(이미 정리됨)` });
expect(await backupRowText(r, RDB_BACKUP), `${RDB_BACKUP} Backup List 에서 제거`).toBe('');
}
for (const r of list) {
const f = await openSql(page, c);
api();
const deleted = await deleteRdb(page, f, r, api);
expect(deleted, `${r.id} 가 Instance List 에 있어야 한다(IV8_S09 선행)`).toBe(true);
}
// 삭제는 CSP 에서 수 분 걸린다: 목록을 갱신하며 행이 사라질 때까지 대기(최대 30분)
const t0 = Date.now();
const cur: Record<string, string> = Object.fromEntries(list.map((r) => [r.id, 'pending']));
while (list.some((r) => cur[r.id]) && Date.now() - t0 < 30 * 60_000) {
await page.waitForTimeout(30_000);
for (const r of list.filter((x) => cur[x.id])) {
cur[r.id] = (await refreshRow(page, c, r.id)).text;
test.info().annotations.push({ type: 'rdb-poll', description: `${r.id} ${Math.round((Date.now() - t0) / 1000)}s: ${cur[r.id] || '(행 없음)'}` });
}
}
for (const r of list) expect(cur[r.id], `${r.id} 목록에서 제거`).toBe('');
});
}tests/iv8/IV8_S11.spec.ts · 37줄 · 해시 7d592bd5 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred } from '../../lib/iv8-os';
import { openSql, pickRdbRow, createSampleData, loadDatabases, hasRdbDb, rdbFlowOf, RDB_CSPS, RDB_DB } from '../../lib/iv8-rdb';
// IV8_S11 : SQL Database: RDBMS 샘플 데이터 생성 (Generate) [정상, CSP별 AWS] (TC-DATA-008)
// 1.Data Migrations > Generate → SQL Database 탭 2.credential 'AWS - aws' / Region 'ap-northeast-2' → Instance List 에서 mcmp-e2e-v07-rdb1aws 선택
// 3.Sample Data → Username 'mcmp' / Password 입력 → Submit 4.하단 Result 성공 메시지
// 5.Back Up → SQL Database 탭에서 같은 인스턴스 선택 → 접속정보 → Load Databases 에 LibraryManagement_0 표시
// 선행: IV8_S09_AWS(rdb1aws available)
// 2026-10-15: 대상표(RDB = AWS·GCP·Alibaba·NCP)에 맞춰 CSP 별 원본(rdbFlowOf(c).src)에서 실행
for (const c of activeCsps(RDB_CSPS)) {
test(`${tid('IV8_S11', c)} SQL Database: RDBMS 샘플 데이터 생성 (Generate)`, async ({ page }) => {
test.setTimeout(30 * 60_000);
const r = rdbFlowOf(c).src;
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openSql(page, c);
api();
await pickRdbRow(page, f, r.id);
const result = await createSampleData(page, f, r, api);
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/fail|error/i);
// Back Up > SQL Database 에서 생성된 DB 조회
const b = await openDm(page, 'Back Up');
await b.getByText(/SQL Database/i).first().click();
await selectCred(page, b, c, 'sourceCredentialSelect');
await page.waitForTimeout(3000);
api();
await pickRdbRow(page, b, r.id, '#mysql-srcHost');
const dbs = await loadDatabases(page, b, r, '#rdbms-load-db-btn', api);
expect(hasRdbDb(dbs), `Back Up 화면 DB 목록에 ${RDB_DB}(대소문자 무시)`).toBe(true);
});
}tests/iv8/IV8_S12.spec.ts · 98줄 · 해시 43e4bb43 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, dmCred, dmRegion } from '../../lib/iv8-os';
import {
pickRdbRow, loadDatabases, resultText, waitResult, stepSummary, clickIn, diagnoseIn, DIAG_ERR,
hasRdbDb, selectRdbDb, rdbFlowOf, RDB_CSPS, RDB_DB, RDB_PW,
} from '../../lib/iv8-rdb';
// IV8_S12 : SQL Database: RDBMS 마이그레이션 [정상, CSP별 AWS] (TC-DATA-009)
// 1.Data Migrations > Migration → SQL Database 탭
// 2.Source: credential 'AWS - aws' / Region 'ap-northeast-2' → mcmp-e2e-v07-rdb1aws 선택 → Username 'mcmp' / Password → Load Databases → LibraryManagement_0 선택 → Verify → Next
// 3.Diagnose Source → Next 4.Target: 'AWS - aws' / 'ap-northeast-2' → mcmp-e2e-v07-rdb3aws 선택 → 접속정보 → Verify → Next
// 5.Diagnose Target → Submit 6.하단 Result 성공 메시지 7.Back Up → SQL Database 에서 rdb3aws 의 DB 목록에 LibraryManagement_0
// 선행: IV8_S09_AWS(rdb1aws·rdb3aws), IV8_S11_AWS(rdb1aws 샘플 데이터)
const P1 = '#collapseOne', P2 = '#collapseTwo', P3 = '#collapseThree', P4 = '#collapseFour';
// 2026-10-15: CSP 별 src→mig(rdbFlowOf) — 대상표 RDB = AWS·GCP·Alibaba·NCP
for (const c of activeCsps(RDB_CSPS)) {
test(`${tid('IV8_S12', c)} SQL Database: RDBMS 마이그레이션`, async ({ page }) => {
test.setTimeout(40 * 60_000);
const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
const { src: RDB_SRC, mig: RDB_MIG } = rdbFlowOf(c);
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openDm(page, 'Migration');
await f.getByText(/SQL Database/i).first().click();
await test.step('1. Source 접속정보 → Database 선택 → Verify → Next', async () => {
await selectCred(page, f, c, 'sourceCredentialSelect');
await page.waitForTimeout(3000);
api();
await pickRdbRow(page, f, RDB_SRC.id, '#mysql-srcHost');
await loadDatabases(page, f, RDB_SRC, '#src-load-db-btn', api);
await selectRdbDb(f, '#mysql-srcDatabase');
await f.locator('#sourceVerifyBtn').click();
await page.waitForTimeout(5000);
note('source-verify', `${await stepSummary(f, P1)} | api: ${api()}`);
await f.locator(`button[data-current="${P1}"]`).click();
await expect(f.locator(P2)).toBeVisible({ timeout: 30_000 });
});
await test.step('2. Diagnose Source → Next', async () => {
note('step2', await stepSummary(f, P2));
const d = await diagnoseIn(page, f, P2, api);
expect(d, 'Diagnose Source 결과 오류 없음').not.toMatch(DIAG_ERR);
await f.locator(`button[data-current="${P2}"]`).click();
await expect(f.locator(P3)).toBeVisible({ timeout: 30_000 });
});
await test.step('3. Target 접속정보 → Verify → Next', async () => {
note('step3-before', await stepSummary(f, P3));
const cred = f.locator(`${P3} select[id*="Credential"]`).first();
await cred.selectOption(dmCred(c)!);
const region = f.locator(`${P3} select[id*="Region"]`).first();
await page.waitForTimeout(2000);
await region.selectOption(dmRegion(c));
await page.waitForTimeout(4000);
const row = f.locator(P3).locator('table tbody tr').filter({ has: f.locator('td', { hasText: new RegExp(`^\\s*${RDB_MIG.id}\\s*$`) }) }).first();
await expect(row, `${RDB_MIG.id} Target Instance List 행`).toBeVisible({ timeout: 60_000 });
await row.locator('td').nth(2).click();
await page.waitForTimeout(2000);
const user = f.locator(P3).getByPlaceholder('Username').first();
const pw = f.locator(P3).getByPlaceholder('Password').first();
await user.fill(RDB_MIG.user);
await pw.fill(RDB_PW);
api();
await clickIn(f, P3, /verify/i);
await page.waitForTimeout(5000);
note('target-verify', `${await stepSummary(f, P3)} | api: ${api()}`);
await f.locator(`button[data-current="${P3}"]`).click();
await expect(f.locator(P4)).toBeVisible({ timeout: 30_000 });
});
await test.step('4. Diagnose Target → Submit → Result', async () => {
note('step4', await stepSummary(f, P4));
const d = await diagnoseIn(page, f, P4, api);
expect(d, 'Diagnose Target 결과 오류 없음').not.toMatch(DIAG_ERR);
const before = await resultText(page, f);
await f.locator('#submitBtn').click();
const result = await waitResult(page, f, before, 30 * 60_000);
note('submit-api', api() || '(API 기록 없음)');
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/\bfail|\berror/i);
});
await test.step('5. Back Up > SQL Database 에서 Target(rdb3aws) DB 목록 확인', async () => {
const b = await openDm(page, 'Back Up');
await b.getByText(/SQL Database/i).first().click();
await selectCred(page, b, c, 'sourceCredentialSelect');
await page.waitForTimeout(3000);
api();
await pickRdbRow(page, b, RDB_MIG.id, '#mysql-srcHost');
const dbs = await loadDatabases(page, b, RDB_MIG, '#rdbms-load-db-btn', api);
expect(hasRdbDb(dbs), `Target(${RDB_MIG.id}) DB 목록에 ${RDB_DB}(대소문자 무시)`).toBe(true);
});
});
}tests/iv8/IV8_S13.spec.ts · 40줄 · 해시 f76297ec · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, backupRowText } from '../../lib/iv8-os';
import { pickRdbRow, loadDatabases, submitBackup, stepSummary, hasRdbDb, selectRdbDb, rdbFlowOf, RDB_CSPS, RDB_DB } from '../../lib/iv8-rdb';
// IV8_S13 : SQL Database: RDBMS 백업 [정상, CSP별 AWS] (TC-DATA-010)
// 1.Data Migrations > Back Up → SQL Database 탭 2.Source: 'AWS - aws' / 'ap-northeast-2' → mcmp-e2e-v07-rdb1aws 선택 → Username 'mcmp' / Password
// → Load Databases(검증) 3.Database 'LibraryManagement_0' 선택 4.Backup Name 'mcmp-e2e-v07-bk2' → Submit 5.Result 성공 · Backup List 에 표시
// 선행: IV8_S11_AWS(rdb1aws 샘플 데이터). 이미 같은 이름 백업이 있으면 실패로 남긴다(IV8_S10 에서 정리 후 재실행).
// 2026-10-15: CSP 별 원본·백업 이름(rdbFlowOf) — 대상표 RDB = AWS·GCP·Alibaba·NCP
for (const c of activeCsps(RDB_CSPS)) {
test(`${tid('IV8_S13', c)} SQL Database: RDBMS 백업`, async ({ page }) => {
test.setTimeout(40 * 60_000);
const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
const { src: RDB_SRC, backup: RDB_BACKUP } = rdbFlowOf(c);
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openDm(page, 'Back Up');
await f.getByText(/SQL Database/i).first().click();
await selectCred(page, f, c, 'sourceCredentialSelect');
await page.waitForTimeout(3000);
expect(await backupRowText(f, RDB_BACKUP), `${RDB_BACKUP} 가 이미 Backup List 에 있음(정리 필요)`).toBe('');
api();
await pickRdbRow(page, f, RDB_SRC.id, '#mysql-srcHost');
const dbs = await loadDatabases(page, f, RDB_SRC, '#rdbms-load-db-btn', api);
expect(hasRdbDb(dbs), `DB 목록에 ${RDB_DB}(대소문자 무시)`).toBe(true);
const dbSel = f.locator('select[name$="[databaseName]"], select[id*="Database"]').first();
const dbId = await dbSel.getAttribute('id');
await selectRdbDb(f, dbId ? `select#${dbId}` : 'select[id*="Database"]');
note('form', await stepSummary(f, 'body'));
const { result, row } = await submitBackup(page, f, RDB_BACKUP, api);
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/\bfail|\berror/i);
expect(row, 'Backup List 에 백업 표시').toContain(RDB_BACKUP);
expect(row.toLowerCase(), 'Backup List 행의 DB(대소문자 무시)').toContain(RDB_DB.toLowerCase());
expect(row, '백업 상태').not.toMatch(/fail|error/i);
});
}tests/iv8/IV8_S14.spec.ts · 59줄 · 해시 e3b90577 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, backupRowText, logHistory } from '../../lib/iv8-os';
import { rdbRow, pickRdbRow, loadDatabases, resultText, waitResult, stepSummary, hasRdbDb, rdbFlowOf, RDB_CSPS, RDB_DB, RDB_PW } from '../../lib/iv8-rdb';
// IV8_S14 : SQL Database: RDBMS 복구 [정상, Target GCP] (TC-DATA-011)
// 1.Data Migrations > Restore → SQL Database 탭 2.Backup List 에서 'mcmp-e2e-v07-bk2'(IV8_S13, AWS rdb1aws 백업) 선택
// 3.Target: 'GCP - gcp' / 'asia-northeast3' → mcmp-e2e-v07-rdb2gpc 선택 → Username 'root' / Password → Submit 4.하단 Result 성공
// 5.Back Up → SQL Database 에서 rdb2gpc 의 DB 목록에 LibraryManagement_0. 선행: IV8_S09_GCP, IV8_S13_AWS
// 2026-10-15: CSP 단독 흐름 — 그 CSP 원본 백업(rdbFlowOf(c).backup) → 같은 CSP 의 rst 로 복구 (2026-10-14 GCP PASS 는 AWS 백업 → rdb2gpc)
for (const c of activeCsps(RDB_CSPS)) {
test(`${tid('IV8_S14', c)} SQL Database: RDBMS 복구`, async ({ page }) => {
test.setTimeout(40 * 60_000);
const { rst: RDB_RST, backup: RDB_BACKUP } = rdbFlowOf(c);
const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openDm(page, 'Restore');
await f.getByText(/SQL Database/i).first().click();
await page.waitForTimeout(3000);
await expect.poll(() => backupRowText(f, RDB_BACKUP), { timeout: 60_000 }).not.toBe('');
const brow = await backupRowText(f, RDB_BACKUP);
expect(brow, `${RDB_BACKUP} 백업 완료 상태(IV8_S13 선행)`).toMatch(/completed/i);
const row = f.locator(`tr.backup-row[data-name="${RDB_BACKUP}"]`).first();
await (await row.count() ? row : f.locator('table tbody tr').filter({ hasText: RDB_BACKUP }).first()).locator('td', { hasText: RDB_BACKUP }).first().click();
await page.waitForTimeout(1500);
note('backup-select', `${brow} | Source=${await f.locator('#sourcePointLabel').inputValue().catch(() => '')}`);
await selectCred(page, f, c, 'targetCredentialSelect');
await page.waitForTimeout(3000);
await expect(rdbRow(f, RDB_RST.id), `${RDB_RST.id} Target Instance List 행`).toBeVisible({ timeout: 60_000 });
await rdbRow(f, RDB_RST.id).locator('td').nth(2).click();
await page.waitForTimeout(2000);
await f.locator('input[placeholder="Username"]:visible').first().fill(RDB_RST.user);
await f.locator('input[placeholder="Password"]:visible').first().fill(RDB_PW);
note('form', await stepSummary(f, 'body'));
api();
const before = await resultText(page, f);
await f.locator('#submitBtn').click();
const result = await waitResult(page, f, before, 30 * 60_000);
note('submit-api', api() || '(API 기록 없음)');
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/\bfail|\berror/i);
// 복구 대상에서 DB 조회
const b = await openDm(page, 'Back Up');
await b.getByText(/SQL Database/i).first().click();
await selectCred(page, b, c, 'sourceCredentialSelect');
await page.waitForTimeout(3000);
api();
await pickRdbRow(page, b, RDB_RST.id, '#mysql-srcHost');
const dbs = await loadDatabases(page, b, RDB_RST, '#rdbms-load-db-btn', api);
expect(hasRdbDb(dbs), `Target(${RDB_RST.id}) DB 목록에 ${RDB_DB}(대소문자 무시)`).toBe(true);
const log = await logHistory(page, 'SQL Database', 'Restore').catch(() => [] as string[]);
note('log', log.slice(0, 3).join(' / ') || '(이력 조회 안 됨)');
});
}tests/iv8/IV8_S15.spec.ts · 34줄 · 해시 aebfcf7f · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder } from '../../lib/iv8-os';
import { PENDING } from '../../lib/iv8-rdb';
import { openNosql, ensureNrdb, waitNrdbReady, nrdbsOf, NRDB_CSPS } from '../../lib/iv8-nrdb';
// IV8_S15 : No-SQL: NRDB 인스턴스 생성 → Instance List 표시 [정상, CSP별 GCP] (TC-DATA-020)
// Data Migrations > Generate → No-SQL 탭 → credential 'GCP - gcp' / Region 'asia-northeast3' → + Instance → Instance ID 입력 → Create
// → Instance List 에 표시. 인스턴스 mcmp-e2e-v07-nrdb1gcp(원본), mcmp-e2e-v07-nrdb2gcp(Migration 대상). 재실행 가능(이미 있으면 생성 생략).
// 2026-10-15: 대상표(NRDB) 중 인스턴스가 있는 GCP·Alibaba·NCP. Alibaba·NCP MongoDB 최소 사양(사용자 지시) — Alibaba 샤딩 클러스터는 생성에 수십 분
for (const c of activeCsps(NRDB_CSPS)) {
test(`${tid('IV8_S15', c)} No-SQL: NRDB 인스턴스 생성 → Instance List 표시`, async ({ page }) => {
test.setTimeout(70 * 60_000);
const list = nrdbsOf(c);
const NRDB_IDS = list.map((s) => s.id);
const api = apiRecorder(page);
await loginAsAdmin(page);
for (const s of list) {
const id = s.id;
const f = await openNosql(page, c);
api();
const row = await ensureNrdb(page, f, s, api);
expect(row, `${id} Instance List 행`).toContain(id);
}
const cur = await waitNrdbReady(page, c, NRDB_IDS, 55 * 60_000);
for (const id of NRDB_IDS) {
expect(cur[id], `${id} 최종 Instance List 행`).toContain(id);
expect(cur[id], `${id} 인스턴스 상태`).not.toMatch(/fail|error/i);
expect(cur[id], `${id} 생성 완료 상태`).not.toMatch(PENDING);
}
});
}tests/iv8/IV8_S16.spec.ts · 40줄 · 해시 a1b5437c · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, logHistory } from '../../lib/iv8-os';
import { openNosql, pickNrdbRow, createNosqlSample, createMongoSample, createDynamoSample, nrdbFlowOf, nrdbKind, NRDB_FLOW_CSPS } from '../../lib/iv8-nrdb';
// IV8_S16 : No-SQL: NRDBMS 샘플 데이터 생성 (Generate) [정상, CSP별] (TC-DATA-012)
// 1.Data Migrations > Generate → No-SQL 탭 2.credential / Region 선택 → Instance List 에서 원본 선택
// 3.Sample Data → 입력 → Submit 4.하단 Result 성공 메시지 5.Log Management(No-SQL / Generate) 성공 이력
// GCP(Firestore): mcmp-e2e-v07-nrdb1gcp 선택 → Database ID(= Instance ID, 자동 입력) 확인 → Submit
// Alibaba·NCP(MongoDB, 2026-10-15): nrdb1ali / mcmpndb1 선택 → Username·Password·Database Name(mcmpe2e) → Submit
// AWS(DynamoDB, 2026-10-15): 인스턴스 목록 없음 — credential·Region 선택 후 'Sample Data'(#nrdb-aws-sampleBtn)
// 클라우드 콘솔 데이터 확인은 이 spec 범위 밖 — 샘플 데이터는 S17(Migration)·S18(Backup) 결과로 간접 확인
// 선행: IV8_S15_<CSP>(원본 available, AWS 제외)
// 2026-10-15: 사용자 대상표(NRDB = AWS·GCP·Alibaba·NCP)
for (const c of activeCsps(NRDB_FLOW_CSPS)) {
test(`${tid('IV8_S16', c)} No-SQL: NRDBMS 샘플 데이터 생성 (Generate)`, async ({ page }) => {
test.setTimeout(30 * 60_000);
const kind = nrdbKind(c);
// NRDB_SAMPLE_ON=mig: 대상(mig)에 샘플 데이터 — MongoDB Migration·Restore 사전 조건(대상에 Database 가 있어야 대상 DB 를 고를 수 있음).
// Sample Data 생성이 대상 Database 를 만드는 작업을 대신한다(2026-10-15 사용자 확인: 우회 아님)
const src = process.env.NRDB_SAMPLE_ON === 'mig' ? nrdbFlowOf(c).mig : nrdbFlowOf(c).src;
if (process.env.NRDB_SAMPLE_ON === 'mig') test.info().annotations.push({ type: 'sample-target', description: `사전 조건: 대상 ${src.id} 에 Database 생성(Sample Data)` });
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openNosql(page, c);
api();
let result = '';
if (kind === 'dynamo') result = await createDynamoSample(page, f, '#nrdb-aws-sampleBtn', api);
else {
await pickNrdbRow(page, f, src.id);
result = kind === 'mongo' ? await createMongoSample(page, f, src, api) : await createNosqlSample(page, f, api);
}
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/fail|error/i);
const log = await logHistory(page, 'No-SQL', 'Generate');
expect(log.join(' / '), 'Log Management 에 Generate 이력').toMatch(/generate/i);
});
}tests/iv8/IV8_S17.spec.ts · 126줄 · 해시 b125a4ea · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin, watchToasts } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, logHistory, dmCred, dmRegion } from '../../lib/iv8-os';
import { resultText, waitResult, stepSummary, diagnoseIn, DIAG_ERR, RDB_PW } from '../../lib/iv8-rdb';
import { pickNrdbRow, mongoLoadDb, nrdbFlowOf, nrdbKind, NRDB_FLOW_CSPS, NRDB_DB } from '../../lib/iv8-nrdb';
// IV8_S17 : No-SQL: NRDBMS 마이그레이션 [정상, CSP별] (TC-DATA-013)
// 1.Data Migrations > Migration → No-SQL 탭 2.Source: credential / Region → 원본 선택 → Next
// 3.Diagnose Source → Next 4.Target: 같은 CSP credential / Region → 대상 선택 → Next 5.Diagnose Target → Submit 6.하단 Result 성공 7.Log Management(No-SQL) 이력
// GCP(Firestore): nrdb1gcp → nrdb2gcp, Instance ID·Database ID 자동 입력. Diagnose 는 GCP 미지원(버튼 비활성)
// Alibaba·NCP(MongoDB, 2026-10-15): 원본 행 선택 → Username·Password → Load Databases → mcmpe2e 선택. 대상 행 선택 → Username·Password
// AWS(DynamoDB, 2026-10-15): 인스턴스 없이 Source·Target 모두 aws / ap-northeast-2 (같은 Region 의 DynamoDB)
// 선행: IV8_S15_<CSP>(원본·대상, AWS 제외), IV8_S16_<CSP>(원본 샘플 데이터)
const P1 = '#collapseOne', P2 = '#collapseTwo', P3 = '#collapseThree', P4 = '#collapseFour';
// 2026-10-15: 사용자 대상표(NRDB = AWS·GCP·Alibaba·NCP)
for (const c of activeCsps(NRDB_FLOW_CSPS)) {
test(`${tid('IV8_S17', c)} No-SQL: NRDBMS 마이그레이션`, async ({ page }) => {
test.setTimeout(40 * 60_000);
const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
const kind = nrdbKind(c);
const { src, mig } = nrdbFlowOf(c);
const api = apiRecorder(page);
// Diagnose: 버튼이 비활성(GCP 미지원)·없음이면 상태만 기록, 활성이면 실행하고 결과에 오류가 없어야 한다
const diag = async (panel: string) => {
const b = f.locator(panel).getByRole('button', { name: /diagnose/i }).first();
const state = !(await b.isVisible().catch(() => false)) ? '버튼 없음' : (await b.isEnabled()) ? '활성' : '비활성';
note(`diagnose-state ${panel}`, `${state} | ${await stepSummary(f, panel)}`);
if (state === '활성') expect(await diagnoseIn(page, f, panel, api), `${panel} Diagnose 결과 오류 없음`).not.toMatch(DIAG_ERR);
};
const clickIfVisible = async (scope: string, name: RegExp, tag: string) => {
const b = f.locator(scope).getByRole('button', { name }).first();
if (await b.isVisible().catch(() => false)) { await b.click(); await page.waitForTimeout(5000); note(tag, `${await stepSummary(f, scope)} | api: ${api() || '(없음)'}`); }
};
await loginAsAdmin(page);
const f = await openDm(page, 'Migration');
await f.getByText(/No.?SQL/i).first().click();
await test.step('1. Source 선택 → Next', async () => {
await selectCred(page, f, c, 'sourceCredentialSelect');
await page.waitForTimeout(3000);
api();
if (kind === 'firestore') await pickNrdbRow(page, f, src.id, '#src-nrdb-gcp-databaseId');
if (kind === 'mongo') {
await pickNrdbRow(page, f, src.id, '#mig-source-mongodb-host');
expect(await mongoLoadDb(page, f, src, 'mig-source-mongodb-', '#src-nrdb-load-db-btn', api, P1), 'Source Database 선택').not.toBe('');
await clickIfVisible(P1, /verify/i, 'source-verify');
}
note('step1', await stepSummary(f, P1));
await f.locator(`button[data-current="${P1}"]`).click();
await expect(f.locator(P2)).toBeVisible({ timeout: 30_000 });
});
await test.step('2. Diagnose Source → Next', async () => {
await diag(P2);
await f.locator(`button[data-current="${P2}"]`).click();
await expect(f.locator(P3)).toBeVisible({ timeout: 30_000 });
});
await test.step('3. Target 선택 → Next', async () => {
await f.locator(`${P3} select[id*="Credential"]`).first().selectOption(dmCred(c)!);
await page.waitForTimeout(2000);
// AWS: 같은 Region 의 같은 테이블로 옮기면 'BatchWriteItem … key element does not match the schema'(500, 2026-10-15) → 대상 Region 을 ap-northeast-1 로 분리
await f.locator(`${P3} select[id*="Region"]`).first().selectOption(kind === 'dynamo' ? 'ap-northeast-1' : dmRegion(c));
await page.waitForTimeout(4000);
if (kind !== 'dynamo') {
const row = f.locator(P3).locator('table tbody tr').filter({ has: f.locator('td', { hasText: new RegExp(`^\\s*${mig.id}\\s*$`) }) }).first();
await expect(row, `${mig.id} Target Instance List 행`).toBeVisible({ timeout: 60_000 });
await row.locator('td').nth(2).click();
await page.waitForTimeout(2000);
}
if (kind === 'mongo') {
note('step3-after-pick', await stepSummary(f, P3));
const user = f.locator(`${P3} input[id*="sername"]:visible`).first();
const pw = f.locator(`${P3} input[type="password"]:visible`).first();
if (await user.count()) await user.fill(mig.user!);
if (await pw.count()) await pw.fill(RDB_PW);
await clickIfVisible(P3, /load databases/i, 'target-load-db');
// Load Databases 뒤 대상 DB select(#mig-target-…)가 생긴다 — 비워 두면 Submit 이 요청을 보내지 않음(2026-10-15 Alibaba·NCP).
// 사전 조건: 대상에 Database 가 있어야 한다 → IV8_S16 을 NRDB_SAMPLE_ON=mig 로 대상에도 실행(Sample Data 가 Database 생성을 대신)
note('target-db-options', await f.locator(P3).evaluate((p) => [...p.querySelectorAll('select')].filter((e) => (e as HTMLElement).offsetParent)
.map((s) => `#${s.id}=${(s as HTMLSelectElement).value} [${[...(s as HTMLSelectElement).options].map((o) => `${o.value}:${(o.textContent || '').trim()}`).join('|')}]`).join(' / ')));
const tsel = f.locator(`${P3} select[id^="mig-target"]`).first();
if (await tsel.count()) {
const opts = await tsel.locator('option').evaluateAll((os) => os.map((o) => (o as HTMLOptionElement).value));
if (opts.includes(NRDB_DB)) await tsel.selectOption(NRDB_DB);
note('target-db', `${await tsel.getAttribute('id')} = ${await tsel.inputValue()} (옵션 ${opts.join('|')})`);
}
await clickIfVisible(P3, /verify/i, 'target-verify');
}
note('step3', await stepSummary(f, P3));
await f.locator(`button[data-current="${P3}"]`).click();
await expect(f.locator(P4)).toBeVisible({ timeout: 30_000 });
});
await test.step('4. Diagnose Target → Submit → Result', async () => {
await diag(P4);
const before = await resultText(page, f);
api();
const toasts = await watchToasts(page);
note('submit-state', `#submitBtn enabled=${await f.locator('#submitBtn').isEnabled()} visible=${await f.locator('#submitBtn').isVisible()}`);
// Result 상자에 오류가 안 나오는 경우(2026-10-15 AWS 500)가 있어 /migrate 응답을 함께 기다린다
const resp = page.waitForResponse((r) => /\/migrate\/nrdbms/i.test(r.url()) && r.request().method() === 'POST', { timeout: 30 * 60_000 }).catch(() => null);
await f.locator('#submitBtn').click();
await page.waitForTimeout(5000);
const early = await toasts();
note('submit-toasts', early.join(' | ') || '(없음)');
note('submit-invalid', await f.evaluate(() => [...document.querySelectorAll('.is-invalid, .invalid-feedback, .text-danger')].filter((e) => (e as HTMLElement).offsetParent).map((e) => `#${(e as HTMLElement).id} ${(e.textContent || '').trim()}`).join(' | ').slice(0, 600)) || '(없음)');
const first = await Promise.race([resp, page.waitForTimeout(60_000).then(() => 'none' as const)]);
if (first === 'none') note('submit-api-wait', '60초 안에 /migrate/nrdbms 요청 없음');
const r = first === 'none' ? null : first; // 요청이 안 나가면 30분을 기다리지 않는다
expect(r, 'Submit 후 /migrate/nrdbms 요청 전송(대상 MongoDB 에 DB 가 없으면 대상 DB 를 고를 수 없어 요청이 나가지 않음)').not.toBeNull();
if (r) { note('submit-response', `${r.status()} ${(await r.text().catch(() => '')).slice(0, 800)}`); expect(r.status(), '/migrate/nrdbms 응답 상태').toBeLessThan(400); }
const result = await waitResult(page, f, before, r ? 5 * 60_000 : 60_000);
note('submit-api', api() || '(API 기록 없음)');
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/\bfail|\berror/i);
});
await test.step('5. Log Management(No-SQL) 이력', async () => {
const rows = await logHistory(page, 'No-SQL', 'Migrate').catch(async () => logHistory(page, 'No-SQL', 'Migration').catch(() => [] as string[]));
note('log', rows.slice(0, 3).join(' / ') || '(이력 조회 안 됨)');
});
});
}tests/iv8/IV8_S18.spec.ts · 42줄 · 해시 785837a0 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, backupRowText } from '../../lib/iv8-os';
import { submitBackup, stepSummary } from '../../lib/iv8-rdb';
import { pickNrdbRow, mongoLoadDb, nrdbFlowOf, nrdbKind, NRDB_FLOW_CSPS } from '../../lib/iv8-nrdb';
// IV8_S18 : No-SQL: NRDBMS 백업 [정상, CSP별] (TC-DATA-014)
// 1.Data Migrations > Back Up → No-SQL 탭 2.Source: credential / Region → 원본 선택 3.Backup Name → Submit 4.Result 성공 · Backup List 에 표시
// GCP(Firestore): nrdb1gcp 선택(Instance ID·Database ID 자동) → 'mcmp-e2e-v07-bk3'
// Alibaba·NCP(MongoDB, 2026-10-15): 원본 선택 → Username·Password → Load Databases → mcmpe2e 선택 → 'mcmp-e2e-v07-bk3ali' / 'bk3ncp'
// AWS(DynamoDB, 2026-10-15): 인스턴스 없이 aws / ap-northeast-2 → 'mcmp-e2e-v07-bk3aws'
// 선행: IV8_S16_<CSP>(원본 샘플 데이터). 같은 이름 백업이 있으면 실패로 남긴다(IV8_S20 에서 정리 후 재실행).
// 2026-10-15: 사용자 대상표(NRDB = AWS·GCP·Alibaba·NCP), 백업 이름은 CSP 별(nrdbFlowOf(c).backup)
for (const c of activeCsps(NRDB_FLOW_CSPS)) {
test(`${tid('IV8_S18', c)} No-SQL: NRDBMS 백업`, async ({ page }) => {
test.setTimeout(40 * 60_000);
const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
const kind = nrdbKind(c);
const { src, backup } = nrdbFlowOf(c);
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openDm(page, 'Back Up');
await f.getByText(/No.?SQL/i).first().click();
await selectCred(page, f, c, 'sourceCredentialSelect');
await page.waitForTimeout(3000);
expect(await backupRowText(f, backup), `${backup} 가 이미 Backup List 에 있음(정리 필요)`).toBe('');
api();
if (kind === 'firestore') await pickNrdbRow(page, f, src.id, '#nrdbms-gcp-databaseId');
if (kind === 'mongo') {
await pickNrdbRow(page, f, src.id, '#nosql-srcHost');
expect(await mongoLoadDb(page, f, src, 'nosql-src', '#nrdb-load-db-btn', api), 'Backup 대상 Database 선택').not.toBe('');
}
note('form', await stepSummary(f, 'body'));
const { result, row } = await submitBackup(page, f, backup, api);
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/\bfail|\berror/i);
expect(row, 'Backup List 에 백업 표시').toContain(backup);
expect(row, '백업 상태').not.toMatch(/fail|error/i);
});
}tests/iv8/IV8_S19.spec.ts · 61줄 · 해시 71e29ab0 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, selectCred, backupRowText, logHistory } from '../../lib/iv8-os';
import { resultText, waitResult, stepSummary, RDB_PW } from '../../lib/iv8-rdb';
import { pickNrdbRow, mongoLoadDb, nrdbFlowOf, nrdbKind, NRDB_FLOW_CSPS } from '../../lib/iv8-nrdb';
// IV8_S19 : No-SQL: NRDBMS 복구 [정상, CSP별] (TC-DATA-015)
// 1.Data Migrations > Restore → No-SQL 탭 2.Backup List 에서 그 CSP 백업(IV8_S18) 선택
// 3.Target: 같은 CSP credential / Region → 복구 대상 선택 → Submit 4.하단 Result 성공 5.Log Management(No-SQL / Restore) 이력
// AWS(DynamoDB): 인스턴스 없이 credential·Region 만 (2026-10-14 PASS 는 GCP bk3 → AWS, 2026-10-15 부터 AWS bk3aws → AWS)
// GCP(Firestore): bk3 → nrdb2gcp
// Alibaba·NCP(MongoDB, 2026-10-15): bk3ali → nrdb2ali / bk3ncp → mcmpndb2, 대상 선택 후 Username·Password
// 클라우드 콘솔 데이터 확인은 이 spec 범위 밖. 선행: IV8_S18_<CSP>
// 2026-10-15: 사용자 대상표(NRDB = AWS·GCP·Alibaba·NCP) — CSP 단독 흐름
for (const c of activeCsps(NRDB_FLOW_CSPS)) {
test(`${tid('IV8_S19', c)} No-SQL: NRDBMS 복구`, async ({ page }) => {
test.setTimeout(40 * 60_000);
const note = (t: string, d: string) => test.info().annotations.push({ type: t, description: d.slice(0, 1500) });
const kind = nrdbKind(c);
const { rst, backup } = nrdbFlowOf(c);
const api = apiRecorder(page);
await loginAsAdmin(page);
const f = await openDm(page, 'Restore');
await f.getByText(/No.?SQL/i).first().click();
await page.waitForTimeout(3000);
await expect.poll(() => backupRowText(f, backup), { timeout: 60_000 }).not.toBe('');
const brow = await backupRowText(f, backup);
expect(brow, `${backup} 백업 완료 상태(IV8_S18 선행)`).toMatch(/completed/i);
const row = f.locator(`tr.backup-row[data-name="${backup}"]`).first();
await (await row.count() ? row : f.locator('table tbody tr').filter({ hasText: backup }).first()).locator('td', { hasText: backup }).first().click();
await page.waitForTimeout(1500);
note('backup-select', `${brow} | Source=${await f.locator('#sourcePointLabel').inputValue().catch(() => '')}`);
await selectCred(page, f, c, 'targetCredentialSelect');
await page.waitForTimeout(3000);
if (kind !== 'dynamo') {
await pickNrdbRow(page, f, rst.id, kind === 'mongo' ? '#nosql-srcHost' : undefined);
if (kind === 'mongo') {
// 대상 계정 → Load Databases → mcmpe2e 선택. DB 를 고르지 않으면 Submit 해도 요청이 나가지 않는다(2026-10-15, S17 과 같은 동작)
expect(await mongoLoadDb(page, f, rst, 'nosql-src', '#nrdb-load-db-btn', api), '복구 대상 Database 선택').not.toBe('');
}
}
note('form', await stepSummary(f, 'body'));
api();
const before = await resultText(page, f);
const resp = page.waitForResponse((r) => /\/restore\/nrdbms/i.test(r.url()) && r.request().method() === 'POST', { timeout: 30 * 60_000 }).catch(() => null);
await f.locator('#submitBtn').click();
const first = await Promise.race([resp, page.waitForTimeout(60_000).then(() => 'none' as const)]);
const r = first === 'none' ? null : first;
expect(r, 'Submit 후 /restore/nrdbms 요청 전송').not.toBeNull();
note('submit-response', `${r!.status()} ${(await r!.text().catch(() => '')).slice(0, 800)}`);
expect(r!.status(), '/restore/nrdbms 응답 상태').toBeLessThan(400);
const result = await waitResult(page, f, before, 5 * 60_000);
note('submit-api', api() || '(API 기록 없음)');
expect(result, 'Result 성공 메시지').toMatch(/success|complete/i);
expect(result, 'Result 오류 없음').not.toMatch(/\bfail|\berror/i);
const log = await logHistory(page, 'No-SQL', 'Restore').catch(() => [] as string[]);
note('log', log.slice(0, 3).join(' / ') || '(이력 조회 안 됨)');
});
}tests/iv8/IV8_S20.spec.ts · 65줄 · 해시 83d022b3 · 시나리오 보기
import { test, expect } from '../../lib/fixtures';
import { activeCsps, tid } from '../../lib/data';
import { loginAsAdmin } from '../../lib/ui';
import { apiRecorder, openDm, deleteBackup, backupRowText } from '../../lib/iv8-os';
import { deleteRdb, rdbRowText } from '../../lib/iv8-rdb';
import { openNosql, refreshNrdb, nrdbsOf, nrdbFlowOf, NRDB_CSPS } from '../../lib/iv8-nrdb';
// AWS(DynamoDB)는 인스턴스가 없어 TC-DATA-021 비대상 — IV8_S18_AWS 백업(bk3aws)만 정리한다(계획 엑셀 행 없음)
for (const c of activeCsps(['AWS'])) {
test(`${tid('IV8_S20', c)} No-SQL: 백업 정리(AWS DynamoDB, 인스턴스 없음)`, async ({ page }) => {
const backup = nrdbFlowOf(c).backup;
const api = apiRecorder(page);
await loginAsAdmin(page);
const r = await openDm(page, 'Restore');
await r.getByText(/No.?SQL/i).first().click();
await page.waitForTimeout(3000);
if (await backupRowText(r, backup)) expect(await deleteBackup(page, r, backup, api), `${backup} 백업 삭제`).toBe(true);
else test.info().annotations.push({ type: 'backup-missing', description: `${backup} Backup List 에 없음(이미 정리됨)` });
expect(await backupRowText(r, backup), `${backup} Backup List 에서 제거`).toBe('');
});
}
// IV8_S20 : No-SQL: NRDB 인스턴스 삭제 (테스트 자원 정리) [정상, CSP별 GCP] (TC-DATA-021)
// 1.Restore > No-SQL → Backup List 에서 'mcmp-e2e-v07-bk3'(IV8_S18) 체크 → Delete → 확인 팝업에 백업 이름 입력 → Delete
// 2.Generate > No-SQL → 'GCP - gcp' / 'asia-northeast3' → Instance List 에서 mcmp-e2e-v07-nrdb1gcp, nrdb2gcp 각각 체크 → Delete → 이름 입력 → Delete
// 3.Instance List·Backup List 에서 사라짐 확인
// (Drive 절차 2 의 'Generate > SQL Database' 는 No-SQL 인스턴스라 No-SQL 탭에서 삭제한다)
// 2026-10-15: 대상표(NRDB) 중 인스턴스가 있는 GCP·Alibaba·NCP, 백업 이름은 CSP 별(nrdbFlowOf(c).backup)
for (const c of activeCsps(NRDB_CSPS)) {
test(`${tid('IV8_S20', c)} No-SQL: NRDB 인스턴스 삭제 (테스트 자원 정리)`, async ({ page }) => {
test.setTimeout(45 * 60_000);
const NRDB_IDS = nrdbsOf(c).map((s) => s.id);
const NRDB_BACKUP = nrdbFlowOf(c).backup;
const api = apiRecorder(page);
await loginAsAdmin(page);
const r = await openDm(page, 'Restore');
await r.getByText(/No.?SQL/i).first().click();
await page.waitForTimeout(3000);
api();
if (await backupRowText(r, NRDB_BACKUP)) {
expect(await deleteBackup(page, r, NRDB_BACKUP, api), `${NRDB_BACKUP} 백업 삭제`).toBe(true);
} else test.info().annotations.push({ type: 'backup-missing', description: `${NRDB_BACKUP} Backup List 에 없음(이미 정리됨)` });
expect(await backupRowText(r, NRDB_BACKUP), `${NRDB_BACKUP} Backup List 에서 제거`).toBe('');
for (const id of NRDB_IDS) {
const f = await openNosql(page, c);
api();
// 목록이 늦게 그려질 수 있어 행을 최대 30초 기다린다(2026-10-14: 3초만 보고 '목록에 없음'으로 판단한 사례). 이미 지워졌으면 기록만 남긴다
const seen = await expect.poll(() => rdbRowText(f, id), { timeout: 30_000, intervals: [3000] }).not.toBe('').then(() => true, () => false);
if (seen) expect(await deleteRdb(page, f, { id } as any, api), `${id} 삭제 요청`).toBe(true);
else test.info().annotations.push({ type: 'nrdb-missing', description: `${id} Instance List 에 없음(이미 삭제됨)` });
}
const t0 = Date.now();
const cur: Record<string, string> = Object.fromEntries(NRDB_IDS.map((id) => [id, 'pending']));
while (NRDB_IDS.some((id) => cur[id]) && Date.now() - t0 < 35 * 60_000) {
await page.waitForTimeout(20_000);
for (const id of NRDB_IDS.filter((x) => cur[x])) {
cur[id] = await refreshNrdb(page, c, id);
test.info().annotations.push({ type: 'nrdb-poll', description: `${id} ${Math.round((Date.now() - t0) / 1000)}s: ${cur[id] || '(행 없음)'}` });
}
}
for (const id of NRDB_IDS) expect(cur[id], `${id} 목록에서 제거`).toBe('');
});
}