Files
AriWeb-Suite/test/e2e/flows/20-commerce.mjs

34 lines
6.8 KiB
JavaScript

/** 흐름 3·24: 상품 목록 → 주문(멱등) → 운영자 입금 확인·승인(HAL 중계) → 권리 활성화 → 고객 알림·감사·운영자 화면 일치. */
import { steps } from '../report.mjs';
export const BUDGET_MS = 40000;
/** 주문을 넣고 운영자 중계로 입금 확인·승인까지 끝낸다(다른 흐름이 재사용). */
export async function orderAndApprove(env, client, { productId = 'team', items = [], key }) {
const prods = await client.get('/api/commerce/products'); const pv = prods.json.products.find((p) => p.productId === productId || p.product_id === productId || p.id === productId); if (!pv) throw new Error(`상품 없음: ${productId} (${prods.status})`);
const o = await client.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: key, items }); if (o.status !== 200) throw new Error(`주문 실패 ${o.status} ${o.text.slice(0, 200)}`);
const paid = await env.relay('POST', `/commerce/orders/${o.json.order.id}/paid`, { ref: 'e2e 입금' }); if (paid.status !== 200) throw new Error(`입금 확인 실패 ${paid.status} ${paid.text.slice(0, 200)}`);
const ap = await env.relay('POST', `/commerce/orders/${o.json.order.id}/approve`, {}); if (ap.status !== 200) throw new Error(`승인 실패 ${ap.status} ${ap.text.slice(0, 200)}`);
return { order: o.json.order, pv, approve: ap.json };
}
export async function run({ env, report, state }) {
const s = steps(); const alice = state.alice; if (!alice) { report.flow(3, 'partial', { note: '고객 세션 없음', blockedBy: 1 }); return; }
try {
const pv = await s.run('상품 목록(공개 버전만·가격·한도·미확정 정책 표시)', async () => { const r = await alice.get('/api/commerce/products'); if (r.status !== 200 || !r.json.products.length) throw new Error(r.text.slice(0, 200)); const team = r.json.products.find((p) => p.productId === 'team'); return { count: r.json.products.length, team: { id: team.id, price: team.price, sites: team.limits?.sites }, deferred: team.deferred?.length, paymentMethods: r.json.paymentMethods?.map((m) => m.id) }; }) && (await alice.get('/api/commerce/products')).json.products.find((p) => p.productId === 'team');
const order = await s.run('주문 제출(수동 결제) → 결제 대기·주문 번호·입금 안내 · 같은 키 재제출은 중복 없음', async () => { const a = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1', items: [{ optionId: 'standalone_mail', qty: 1 }] }); if (a.status !== 200) throw new Error(a.text.slice(0, 300)); const b = await alice.post('/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'e2e-order-1' }); if (b.json.created !== false) throw new Error('멱등 실패'); return { orderNo: a.json.order.order_no, status: a.json.order.status, total: a.json.order.total, dueAt: a.json.order.due_at, hasInstructions: !!a.json.order.payment_instructions }; }) && (await alice.get('/api/commerce/orders')).json.orders[0];
await s.run('입금 확인 전 승인 거부(409 payment_not_confirmed)', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (r.status !== 409) throw new Error(`${r.status} ${r.text.slice(0, 200)}`); return r.json.error; });
await s.run('운영자 입금 확인 → 승인 대기 목록에 권리 변화·현재 사용량 표시', async () => { const r = await env.relay('POST', `/commerce/orders/${order.id}/paid`, { ref: 'e2e 입금 확인' }); if (r.status !== 200) throw new Error(r.text.slice(0, 200)); const l = await env.relay('GET', '/commerce/orders?status=awaiting_approval'); const o = l.json.orders.find((x) => x.id === order.id); return { status: r.json.order.status, delta: o?.entitlementDelta?.map((d) => d.resource) }; });
const ap = await s.run('운영자 승인 → 권리 활성화(재승인은 중복 없음)', async () => { const a = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); if (a.status !== 200) throw new Error(a.text.slice(0, 200)); const b = await env.relay('POST', `/commerce/orders/${order.id}/approve`, {}); return { granted: a.json.granted, already: b.json.already }; });
const bill = await s.run('고객 결제 화면: 활성 상품·권리·다음 청구일', async () => { const r = await alice.get('/api/commerce/billing'); const sites = r.json.entitlements?.find((e) => e.resource === 'sites'); if (!sites || sites.purchased < 1) throw new Error(JSON.stringify(r.json).slice(0, 300)); return { sites: sites.purchased, subs: r.json.subscriptions?.length, nextBilling: r.json.subscriptions?.[0]?.next_billing_at }; });
state.order = order; state.teamProduct = pv;
report.flow(3, 'verified_local', { steps: s.list, note: '상품 목록→주문(멱등·수동 결제)→입금 확인 전 승인 거부→입금 확인→승인→권리 활성화→재승인 중복 없음→청구 화면', evidence: { ap, bill } });
} catch (e) { report.flow(3, 'failed', { steps: s.list, note: e.message }); report.bug({ title: '주문·승인·권리 활성화 흐름 실패', file: 'portal/commerce/api.mjs', route: '/api/commerce/orders, /internal/ops/commerce/orders/:id/{paid,approve}', expected: '승인 뒤 권리 활성화', actual: e.message, repro: '20-commerce.mjs', flows: [3] }); return; }
const s2 = steps();
try {
const alice = state.alice; const order = state.order;
const n = await s2.run('고객 알림 센터에 승인 알림', async () => { const r = await alice.get('/api/notifications'); const x = (r.json.notifications || r.json.items || []).find((y) => /승인/.test(y.title) && (y.subject_id === order.id || /주문/.test(y.title))); if (!x) throw new Error('알림 없음: ' + JSON.stringify(r.json).slice(0, 200)); return { title: x.title, category: x.category, link: x.link }; });
const a = await s2.run('감사 기록: order.approve 는 삭제 불가(immutable)', async () => { const r = await alice.get('/api/audit?action=order.approve'); const x = (r.json.items || [])[0]; if (!x || !x.immutable) throw new Error(JSON.stringify(r.json).slice(0, 200)); return { action: x.action, immutable: x.immutable, actor: x.actor_type }; });
const same = await s2.run('운영자 화면과 고객 주문 화면의 상태 일치', async () => { const o = await env.relay('GET', `/commerce/orders/${order.id}`); const c = await alice.get(`/api/commerce/orders/${order.id}`); if (o.json.order.status !== c.json.order.status) throw new Error(`${o.json.order.status} vs ${c.json.order.status}`); return { status: c.json.order.status, approvedAt: c.json.order.approved_at }; });
report.flow(24, 'verified_local', { steps: s2.list, note: '승인 → 고객 알림·불변 감사 기록·운영자/고객 화면 상태 일치(주문 기준)', evidence: { n, a, same } });
} catch (e) { report.flow(24, 'partial', { steps: s2.list, note: e.message }); }
}