Skip to content

Test Specs IV8

dogfootman edited this page Oct 15, 2026 · 5 revisions

Test Specs — IV8 Data Migrations

생성: 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

IV8_S01

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')) ?? '' });
});

IV8_S02

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);
});

IV8_S03

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);
  });
}

IV8_S04

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'));
  });
}

IV8_S05

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);
  });
}

IV8_S06

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);
  });
}

IV8_S07

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);
  });
}

IV8_S08

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('');
  });
}

IV8_S09

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);
    }
  });
}

IV8_S10

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('');
  });
}

IV8_S11

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);
  });
}

IV8_S12

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);
    });
  });
}

IV8_S13

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);
  });
}

IV8_S14

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(' / ') || '(이력 조회 안 됨)');
  });
}

IV8_S15

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);
    }
  });
}

IV8_S16

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);
  });
}

IV8_S17

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(' / ') || '(이력 조회 안 됨)');
    });
  });
}

IV8_S18

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);
  });
}

IV8_S19

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(' / ') || '(이력 조회 안 됨)');
  });
}

IV8_S20

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('');
  });
}

Clone this wiki locally