Files
AriWeb-Suite/test/e2e/flows/70-lifecycle.mjs
T

47 lines
11 KiB
JavaScript

/** 흐름 19·20·21·32·34: 사이트 수 반환 점검표·대표 메일 전환 조건, 소유권 이전(요청→수락→보류→재확인→심사·승인→원자적 완료), 중지·해지·보존 표시, Source 옵션 만료 읽기 전용. */
import { steps } from '../report.mjs';
import { orderAndApprove } from './20-commerce.mjs';
export const BUDGET_MS = 60000;
export async function run({ env, report, state }) {
const alice = state.alice; const site = state.site; if (!alice || !site) { for (const n of [19, 20, 21, 32, 34]) report.flow(n, 'partial', { note: '선행 흐름 없음', blockedBy: 4 }); return; }
const cur = (await alice.get(`/api/sites/${site.id}`)).json.site; const live = ['live', 'needs_domain'].includes(cur.lifecycle);
// ---- 32 Source 옵션
{ const s = steps(); try {
await s.run('Source 옵션 주문(대상 사이트)·승인 → source 권리', async () => orderAndApprove(env, alice, { productId: 'team', items: [{ optionId: 'source', qty: 1, targetId: site.id }], key: 'e2e-source-opt' }).then((r) => ({ granted: r.approve.granted })), { soft: true });
const act = await s.run('Source 옵션 활성화(권리 안에서) → 저장소·브랜치·변경 이력·clone 정보', async () => { const a = await alice.post(`/api/source/${site.id}/activate`); const r = await alice.get(`/api/source/${site.id}`); return { activate: a.status, error: a.json?.error, status: r.json.option?.status, provider: r.json.source?.provider, changes: r.json.source?.changes?.length, clone: !!r.json.cloneInfo }; });
const ex = await s.run('export(저장본 내려받기)', async () => { const r = await alice.get(`/api/source/${site.id}/export`); return { status: r.status, type: r.headers['content-type'] }; });
const un = await s.run('옵션 해제 → 30일 읽기 전용(만료일·삭제 예정일·재활성화 안내) · push 차단', async () => { const r = await alice.post(`/api/source/${site.id}/unlink`); if (r.status !== 200) throw new Error(r.text.slice(0, 300)); return { status: r.json.option?.status, deleteAfter: r.json.option?.delete_after, readonlyUntil: r.json.option?.readonly_until }; });
const sweep = await s.run('삭제 정리 작업(sweep)은 삭제 예정일 전이면 지우지 않음', async () => { const r = await env.relay('POST', '/source/sweep', {}); const x = await alice.get(`/api/source/${site.id}`); return { sweep: r.status, statusAfter: x.json.option?.status }; });
report.flow(32, 'partial', { steps: s.list, note: '활성화·export·해제→읽기 전용(삭제 예정일)까지 확인. 30일 뒤 삭제 완료는 시간 경과가 필요(단위 테스트 test/sites.test.mjs 에서 sweep 로 확인)', evidence: { act, ex, un, sweep } });
} catch (e) { report.flow(32, 'partial', { steps: s.list, note: e.message }); } }
// ---- 21·34 생명주기 표시
{ const s = steps(); try {
const v = await s.run('§23.3 한 화면 보기: 공개/관리자 접근·사이트 수·청구·도메인/메일/백업 동작·내보내기·되돌릴 수 없는 시점·보존·다음 알림', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}`); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const view = r.json.view || {}; return { state: view.site?.label, siteCount: view.siteCountConsumed, publicAccess: view.publicAccess, retention: view.retentionPolicy, pauseImpactKeys: Object.keys(r.json.pauseImpact || {}), cancelImpactKeys: Object.keys(r.json.cancelImpact || {}), steps14: r.json.siteCount?.steps?.length }; });
const pause = await s.run('일시 중지(확인 필수) 또는 현재 상태에서 거부 사유', async () => { const r0 = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, {}); const r = await alice.post(`/api/lifecycle/sites/${site.id}/pause`, { confirm: true }); return { noConfirm: r0.json?.error, status: r.status, error: r.json?.error, lifecycle: r.json?.site?.lifecycle }; });
if (pause.status === 200) await s.run('재개', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/resume`); return { status: r.status, lifecycle: r.json?.site?.lifecycle }; }, { soft: true });
const mailLc = await s.run('메일 서비스 생명주기 화면(해지된 독립 메일: 송수신 중지·보존 종료·삭제 예정·백업 미제공)', async () => { if (!state.standaloneMailId) return { skipped: true }; const r = await alice.get(`/api/lifecycle/mail/${state.standaloneMailId}`); return { status: r.status, sendReceive: r.json.view?.sendReceive, retentionUntil: !!r.json.view?.retentionUntil, deleteAfter: !!r.json.view?.deleteAfter, backupNote: r.json.view?.backupNote }; });
report.flow(21, 'partial', { steps: s.list, note: live ? '중지·재개·생명주기 화면 확인' : '생명주기 화면·중지/해지 영향 안내·보존 정책 표시 확인. 운영 중 사이트의 실제 중지·해지는 배포 성공(흐름 6) 필요', blockedBy: live ? null : 6, evidence: { v, pause, mailLc } });
report.flow(34, 'partial', { steps: s.list, note: '해지된 메일 서비스의 읽기 전용 보존·삭제 예정 표시와 사이트 생명주기 화면의 단계별 항목 확인. 사이트의 해지 예약→보존→삭제 전이는 운영 중 사이트 필요(단위 테스트 test/lifecycle.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { v, mailLc } });
} catch (e) { report.flow(21, 'partial', { steps: s.list, note: e.message }); report.flow(34, 'partial', { steps: s.list, note: e.message }); } }
// ---- 19 사이트 수 반환 점검표
{ const s = steps(); try {
const ck = await s.run('§14 반환 점검표(8단계)·차단 사유(대표 메일 유지 선택·독립 옵션)', async () => { const r = await alice.get(`/api/lifecycle/sites/${site.id}/site-count-return`); return { ok: r.json.checklist?.ok, blocking: r.json.checklist?.blocking, steps: r.json.checklist?.steps?.length }; });
const keep = await s.run('메일 유지 선택: 독립 옵션 부족이면 반환하지 않음 / 종료 선택 규칙', async () => { const r = await alice.post(`/api/lifecycle/sites/${site.id}/site-count-return`, { mailChoice: 'keep', acknowledge: true, confirm: true }); return { status: r.status, error: r.json?.error, detail: r.json?.detail ? Object.keys(r.json.detail) : null }; });
report.flow(19, 'partial', { steps: s.list, note: '반환 점검표와 메일 유지/종료 분기 규칙 확인. 실제 반환은 운영 사이트(사이트 수 사용 중) 필요(단위 테스트 test/lifecycle.test.mjs·test/mail.test.mjs 로 확인)', blockedBy: live ? null : 6, evidence: { ck, keep } });
} catch (e) { report.flow(19, 'partial', { steps: s.list, note: e.message }); } }
// ---- 20 소유권 이전 (앨리스 팀 → 밥 개인)
{ const s = steps(); try {
const bob = state.bob; if (!bob) throw new Error('밥 없음'); await bob.post('/api/groups/switch', { groupId: state.bobPersonal || (await bob.get('/api/groups')).json.groups.find((g) => g.kind === 'personal').id });
const pv = await s.run('이전 범위 미리보기(사이트·도메인·대표 메일·배정·진행 작업·미납 / 제외 항목)', async () => { const r = await alice.post('/api/transfer/preview', { siteIds: [site.id] }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); return { sites: r.json.scope?.sites?.length, domains: r.json.scope?.sites?.[0]?.domains?.length, siteMail: r.json.scope?.sites?.[0]?.siteMail?.length, needed: r.json.scope?.needed, excluded: r.json.scope?.excluded?.length, jobs: r.json.scope?.jobs }; });
const tsite = (await alice.post('/api/sites', { name: '이전 대상 사이트', projectMode: 'none' })).json.site;
const rq = await s.run('요청(보내는 그룹, 초안 사이트) → 받는 사용자 알림', async () => { const r = await alice.post('/api/transfer', { toEmail: 'bob@e2e.localhost', siteIds: [tsite.id], confirm: true }); if (r.status !== 200) throw new Error(`${r.status} ${r.text.slice(0, 300)}`); const n = await bob.get('/api/notifications'); return { id: r.json.transfer.id, status: r.json.transfer.status, expiresAt: r.json.transfer.expires_at, bobNotified: (n.json.notifications || n.json.items || []).some((x) => /이전|소유권/.test(x.title)) }; });
await s.run('(정리) 받는 쪽 권리에 맞게 범위 조정: 백업 옵션 종료·메일함 배정 6→5(받는 그룹 베이직 5개)', async () => { const e = await alice.post(`/api/backup/sites/${site.id}/option/end`, { confirm: true }); const a = state.mailServiceId ? await alice.post(`/api/usage/allocate/mail_service:${state.mailServiceId}`, { resource: 'mailboxes', amount: 5 }) : null; return { endBackup: e.status, endError: e.json?.error, alloc: a?.status, allocError: a?.json?.error }; }, { soft: true });
const acc = await s.run('받는 그룹 수락 → 권리 부족이면 보류(필요한 상품 안내)', async () => { const r = await bob.post(`/api/transfer/${rq.id}/accept`, { confirm: true }); return { status: r.status, tstatus: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; });
if (acc.tstatus === 'on_hold') await s.run('받는 쪽 정리(실패 배포의 예약 반환) → 재확인 → 심사 단계', async () => { for (const bs of state.bobSites || []) await bob.post(`/api/sites/${bs.id}/cleanup`); const r = await bob.post(`/api/transfer/${rq.id}/recheck`); return { status: r.json?.transfer?.status, hold: r.json?.transfer?.hold }; }, { soft: true });
const ap = await s.run('운영자 심사(소유 증빙·도메인 확인) 미입력 거부 → 최종 확인 일치 승인 → 원자적 완료·청구 전환 시각', async () => { const bad = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: {}, confirm: { fromGroupId: tsite.group_id, toGroupId: 'x' } }); const to = (await alice.get(`/api/transfer/${rq.id}`)).json.transfer?.to_group_id; const ok = await env.relay('POST', `/transfer/${rq.id}/approve`, { operator: 'e2e', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: tsite.group_id, toGroupId: to } }); return { bad: bad.json?.error, ok: ok.status, status: ok.json?.transfer?.status, cutover: ok.json?.transfer?.billing_cutover_at, error: ok.json?.error }; });
const after = await s.run('완료 뒤: 보내는 쪽은 접근 불가(404), 받는 쪽 대시보드·사용량 일치, 불변 감사', async () => { const a = await alice.get(`/api/sites/${tsite.id}`); const b = await bob.get(`/api/sites/${tsite.id}`); const au = await bob.get('/api/audit?action=ownership.complete'); return { aliceSees: a.status, bobSees: b.status, bobUsageSites: (await bob.get('/api/usage')).json.resources?.find((x) => x.resource === 'sites')?.allocated, audit: (au.json.items || [])[0]?.immutable }; });
report.flow(20, ap.status === 'completed' ? 'verified_local' : 'partial', { steps: s.list, note: ap.status === 'completed' ? '미리보기→요청·알림→수락(보류·재확인)→심사·최종 확인→원자적 완료→접근 전환·감사' : '요청·수락·보류·심사 단계 확인(완료 여부는 증거 참조)', evidence: { pv, rq, acc, ap, after } });
} catch (e) { report.flow(20, 'partial', { steps: s.list, note: e.message }); } }
}