/** test/commerce.test.mjs — 상품·주문·수동 결제·승인·권리·축소·해지·갱신·초과 사용·운영자 API 회귀 테스트. */ import { test, eq, ok, done } from '../lib/test.mjs'; import { makeDeps, seedGroup, appFor, call } from './helpers.mjs'; import { invoke } from '../lib/http.mjs'; import { signHeaders } from '../lib/serviceauth.mjs'; import * as quota from '../lib/quota.mjs'; import * as cat from '../portal/commerce/catalog.mjs'; import * as ord from '../portal/commerce/orders.mjs'; import { newId } from '../lib/crypto.mjs'; async function setup({ verified = true } = {}) { const deps = await makeDeps({ opsAuthBypass: true, autoSeedCatalog: true }); const who = seedGroup(deps, { verified }); const app = await appFor(deps, ['commerce', 'usage'], who); return { deps, who, app, store: deps.store }; } const ops = (app, method, path, body) => invoke(app, method, path, { body, headers: { 'x-ops-actor': 'tester' } }); const mkSite = (store, gid, name = '사이트') => { const id = newId('s_'); const now = new Date().toISOString(); store.run('INSERT INTO sites(id, group_id, name, created_at, updated_at) VALUES(?,?,?,?,?)', id, gid, name, now, now); return id; }; await test('catalog: 시드 검증·중복 시드 건너뜀·빈 필드 탐지·미확정 정책 렌더링', async () => { const doc = cat.loadCatalogFile(); eq(cat.validateCatalog(doc), []); const bad = JSON.parse(JSON.stringify(doc)); bad.products[0].limits.mail_msgs = 0; delete bad.products[1].policy.retention_days; ok(cat.validateCatalog(bad).length >= 2, '빈 필드 탐지'); const { store, app } = await setup(); eq(cat.seedCatalog(store).inserted, 0, '자동 시드 뒤 재시드는 0건'); eq(cat.publishedProducts(store).length, 2); const r = await call(app, 'GET', '/api/commerce/products'); eq(r.status, 200); const p = r.json.products[0]; ok(p.policy.alias_counting.deferred); eq(p.policy.alias_counting.label, '미확정 정책'); ok(p.deferred.length >= 3); eq(r.json.paymentMethods.map((m) => m.id), ['manual_transfer']); eq(r.json.domainRegistration.enabled, false); }); await test('order: 멱등(같은 키·같은 상품 대기 중)·약관 필수·자동 결제 거부·주문 번호·입금 안내', async () => { const { app, store } = await setup(); const pv = cat.publishedProducts(store)[0]; let r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: false, idempotencyKey: 'k1' }); eq(r.json.error, 'terms_required'); r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, paymentMethod: 'card' }); eq(r.json.error, 'payment_method_unsupported'); r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'k1' }); eq(r.status, 200); ok(r.json.created); const o = r.json.order; ok(/^AW-\d{8}-\d{4}$/.test(o.order_no)); eq(o.status, 'awaiting_payment'); eq(o.total, 19000 + 1900); ok(o.payment_instructions.account); ok(o.due_at); const r2 = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'k1' }); eq(r2.json.created, false); eq(r2.json.order.id, o.id); const r3 = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'k2' }); eq(r3.json.created, false, '같은 상품 결제 대기 중이면 중복 주문 없음'); eq(store.get('SELECT COUNT(*) AS n FROM orders').n, 1); const lst = await call(app, 'GET', '/api/commerce/orders'); eq(lst.json.orders.length, 1); eq(lst.json.orders[0].statusLabel, '결제 대기'); }); await test('order: 이메일 미확인 사용자는 유료 상품 주문 불가', async () => { const { app, store } = await setup({ verified: false }); const pv = cat.publishedProducts(store)[0]; const r = await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true }); eq(r.status, 403); eq(r.json.error, 'email_unverified'); }); await test('approval: 입금 확인 전 승인 거부 → 입금 확인 → 승인 → 권리·구독 생성, 재승인해도 권리 중복 없음, 알림·감사', async () => { const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0]; const sid = mkSite(store, who.group.id); const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, items: [{ optionId: 'backup', qty: 1, targetId: sid }, { optionId: 'extra_disk', qty: 2 }] })).json.order; eq(o.total, Math.round((19000 + 7000 + 6000) * 1.1)); let r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); eq(r.status, 409); eq(r.json.error, 'payment_not_confirmed'); r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, { ref: '홍길동 10/26' }); eq(r.json.order.status, 'awaiting_approval'); const list = await ops(app, 'GET', '/internal/ops/commerce/orders?status=awaiting_approval'); eq(list.json.orders.length, 1); ok(list.json.orders[0].entitlementDelta.find((d) => d.resource === 'backup' && d.target === sid)); ok(list.json.orders[0].group.name); r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); eq(r.status, 200); eq(r.json.already, false); ok(r.json.granted >= 7); const f = quota.figures(store, who.group.id, 'web_disk'); eq(f.purchased, 5368709120 * 3); eq(quota.figures(store, who.group.id, 'backup').purchased, 1); eq(quota.figures(store, who.group.id, 'sites').purchased, 1); r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); eq(r.json.already, true); eq(quota.figures(store, who.group.id, 'web_disk').purchased, 5368709120 * 3, '중복 부여 없음'); eq(store.get('SELECT COUNT(*) AS n FROM quota_entitlements').n, r.json.order.entitlement_ids.length); const sub = ord.activeSubscription(store, who.group.id); ok(sub); eq(sub.status, 'active'); ok(sub.next_billing_at > sub.started_at); ok(store.get("SELECT 1 AS x FROM notifications WHERE group_id=? AND title LIKE '%승인%'", who.group.id)); eq(store.get("SELECT immutable FROM audit_log WHERE action='order.approve'").immutable, 1); const b = await call(app, 'GET', '/api/commerce/billing'); eq(b.json.subscriptions.length, 1); eq(b.json.monthEstimate, o.total); eq(b.json.awaitingTotal, 0); ok(b.json.entitlements.find((e) => e.resource === 'sites').purchased === 1); const inv = await call(app, 'GET', `/billing/invoice/${o.id}`); ok(inv.text.includes('영수증')); }); await test('order: 거절 사유 필수·취소·기한 만료·운영자 메모·환불 원장', async () => { const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0]; const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true })).json.order; let r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/reject`, {}); eq(r.json.error, 'reason_required'); r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/reject`, { reason: '입금자 불일치' }); eq(r.json.order.status, 'rejected'); ok(r.json.order.nextStep.includes('입금자 불일치')); const o2 = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'x' })).json.order; r = await call(app, 'POST', `/api/commerce/orders/${o2.id}/cancel`); eq(r.json.order.status, 'cancelled'); const o3 = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true, idempotencyKey: 'y' })).json.order; store.run('UPDATE orders SET due_at=? WHERE id=?', new Date(0).toISOString(), o3.id); eq(ord.expireOrders(store).length, 1); eq(ord.getOrder(store, o3.id).status, 'expired'); r = await ops(app, 'POST', '/internal/ops/commerce/credits', { groupId: who.group.id, kind: 'refund', amount: 5000, reason: '중복 입금' }); eq(r.status, 200); const b = await call(app, 'GET', '/api/commerce/billing'); eq(b.json.credits.length, 1); r = await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/note`, { note: '재처리 예정' }); eq(r.json.order.note, '재처리 예정'); }); await test('downsize: 사용량 초과면 보류·정리 목록, 정리 후 재검증하면 축소 주문, 승인 시 권리 교체(즉시 회수 없음)', async () => { const { app, store, who } = await setup(); const [basic, team] = cat.publishedProducts(store).sort((a, b) => a.price - b.price); const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: team.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); const s1 = mkSite(store, who.group.id, 'A'); const s2 = mkSite(store, who.group.id, 'B'); for (const s of [s1, s2]) { quota.commit(store, quota.reserve(store, { groupId: who.group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s }).id); } quota.setAllocation(store, { groupId: who.group.id, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 8 * 1024 ** 3 }); let r = await call(app, 'POST', '/api/commerce/downsize/preview', { productVersionId: basic.id }); eq(r.json.shortages.map((s) => s.resource).sort(), ['sites', 'web_disk']); eq(r.json.shortages.find((s) => s.resource === 'sites').reduceBy, 1); r = await call(app, 'POST', '/api/commerce/downsize/request', { productVersionId: basic.id, acceptTerms: true }); eq(r.json.fits, false); ok(r.json.hold); eq(r.json.hold.status, 'holding'); eq(quota.figures(store, who.group.id, 'sites').purchased, 3, '보류 중 권리 회수 없음'); r = await call(app, 'POST', '/api/commerce/downsize/revalidate'); eq(r.json.fits, false); // 정리: 사이트 B 반환, 디스크 배정 축소 quota.finishReturn(store, quota.allocationOf(store, who.group.id, 'sites', 'site', s2).id); quota.setAllocation(store, { groupId: who.group.id, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 4 * 1024 ** 3 }); r = await call(app, 'POST', '/api/commerce/downsize/revalidate'); eq(r.json.fits, true); eq(r.json.order.kind, 'downsize'); eq(r.json.order.status, 'awaiting_payment'); await ops(app, 'POST', `/internal/ops/commerce/orders/${r.json.order.id}/paid`, {}); const a = await ops(app, 'POST', `/internal/ops/commerce/orders/${r.json.order.id}/approve`); eq(a.status, 200); eq(quota.figures(store, who.group.id, 'sites').purchased, 1); eq(quota.figures(store, who.group.id, 'sites').allocated, 1); eq(ord.activeSubscription(store, who.group.id).product_version_id, basic.id); eq(store.get("SELECT status FROM downsize_holds").status, 'applied'); }); await test('cancel/renewal: 해지 예약·철회·적용 시 권리 종료, 갱신 주문 생성·미납 제한 표시 일치', async () => { const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0]; const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); let r = await call(app, 'GET', '/api/commerce/cancel/preview'); ok(r.json.effectiveAt); eq(r.json.retention.site, 30); r = await call(app, 'POST', '/api/commerce/cancel'); eq(r.json.subscription.status, 'cancel_scheduled'); r = await call(app, 'POST', '/api/commerce/cancel/undo'); eq(r.json.subscription.status, 'active'); // 갱신: 청구일을 3일 뒤로 당기면 갱신 주문이 생긴다 const sub = ord.activeSubscription(store, who.group.id); store.run('UPDATE subscriptions SET next_billing_at=? WHERE id=?', new Date(Date.now() + 3 * 86400000).toISOString(), sub.id); const pr = ord.processRenewals(store); eq(pr.created, [sub.id]); const s2 = ord.activeSubscription(store, who.group.id); eq(s2.status, 'awaiting_renewal_payment'); ok(s2.renewal_order_id); ok(s2.restrict_at > s2.payment_due_at); const ro = ord.getOrder(store, s2.renewal_order_id); eq(ro.kind, 'renewal'); eq(ro.total, o.total); eq(ord.processRenewals(store).created.length, 0, '중복 생성 없음'); const g = await ops(app, 'GET', `/internal/ops/commerce/groups/${who.group.id}`); eq(g.json.billing.subscriptions[0].restrict_at, s2.restrict_at, '고객·운영자 동일 표시'); store.run('UPDATE subscriptions SET restrict_at=? WHERE id=?', new Date(0).toISOString(), sub.id); eq(ord.processRenewals(store).restricted, [sub.id]); eq(ord.activeSubscription(store, who.group.id).status, 'restricted'); await ops(app, 'POST', `/internal/ops/commerce/orders/${ro.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${ro.id}/approve`); const s3 = ord.activeSubscription(store, who.group.id); eq(s3.status, 'active'); ok(s3.next_billing_at > s2.next_billing_at); eq(quota.figures(store, who.group.id, 'sites').purchased, 1, '갱신은 권리 유지'); // 해지 적용 await call(app, 'POST', '/api/commerce/cancel'); store.run('UPDATE subscriptions SET cancel_at=? WHERE id=?', new Date(0).toISOString(), sub.id); ord.processRenewals(store); eq(quota.figures(store, who.group.id, 'sites').purchased, 0); eq(ord.listSubscriptions(store, who.group.id)[0].status, 'cancelled'); }); await test('overage: 상한·기간 필수, 상품에 없는 자원 거부, 철회', async () => { const { app, store, who } = await setup(); const pv = cat.publishedProducts(store)[0]; const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: pv.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); const ps = new Date().toISOString(); const pe = new Date(Date.now() + 30 * 86400000).toISOString(); let r = await call(app, 'POST', '/api/commerce/overage', { resource: 'web_out', periodStart: ps, periodEnd: pe }); eq(r.json.error, 'cap_required'); r = await call(app, 'POST', '/api/commerce/overage', { resource: 'web_disk', capAmount: 1000, periodStart: ps, periodEnd: pe }); eq(r.json.error, 'overage_not_offered'); r = await call(app, 'POST', '/api/commerce/overage', { resource: 'web_out', capAmount: 5000, periodStart: ps, periodEnd: pe }); eq(r.status, 200); ok(ord.activeOverage(store, who.group.id, 'web_out')); r = await call(app, 'POST', '/api/commerce/overage', { revoke: true, resource: 'web_out' }); eq(r.json.revoked, 1); eq(ord.activeOverage(store, who.group.id, 'web_out'), null); }); await test('ops products: 새 버전 생성은 옛 버전을 바꾸지 않고, 공개하면 옛 버전 신규 판매 중단·기존 구독 유지; 설정 변경', async () => { const { app, store, who } = await setup(); const basic = cat.publishedProducts(store).find((p) => p.product_id === 'basic'); const o = (await call(app, 'POST', '/api/commerce/orders', { productVersionId: basic.id, acceptTerms: true })).json.order; await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/paid`, {}); await ops(app, 'POST', `/internal/ops/commerce/orders/${o.id}/approve`); let r = await ops(app, 'POST', '/internal/ops/commerce/products', { productId: 'basic', baseVersionId: basic.id, patch: { price: 21000 } }); eq(r.status, 200); eq(r.json.product.version, 2); eq(r.json.product.published, false); eq(cat.getProductVersion(store, basic.id).price, 19000, '옛 버전 가격 유지'); eq(cat.publishedProducts(store).find((p) => p.product_id === 'basic').version, 1); r = await ops(app, 'POST', `/internal/ops/commerce/products/${r.json.product.id}/publish`, {}); eq(r.json.product.published, true); eq(cat.publishedProducts(store).find((p) => p.product_id === 'basic').version, 2); eq(cat.getProductVersion(store, basic.id).published, false); eq(ord.activeSubscription(store, who.group.id).product_version_id, basic.id, '기존 고객은 옛 버전 유지'); r = await ops(app, 'POST', '/internal/ops/commerce/products', { productId: 'basic', patch: { price: 1, limits: { sites: 0 } } }); eq(r.status, 400); eq(r.json.error, 'product_invalid'); r = await ops(app, 'POST', '/internal/ops/commerce/settings', { domain_registration: { enabled: true, registrar: 'test-registrar' } }); eq(r.status, 200); eq((await call(app, 'GET', '/api/commerce/domain-registration')).json.enabled, true); r = await ops(app, 'POST', '/internal/ops/commerce/settings', { 'payment_instructions.secret': 'x' }); eq(r.status, 200, '알 수 없는 키는 무시'); const lst = await ops(app, 'GET', '/internal/ops/commerce/products'); eq(lst.json.products.length, 3); ok(lst.json.products.find((p) => p.id === basic.id).subscribers === 1); }); await test('ops auth: 서명 없는 호출 401, 보관소 비밀로 서명한 호출 허용, 시계 차이·다른 키 거부', async () => { const deps = await makeDeps({ autoSeedCatalog: true }); const who = seedGroup(deps); const app = await appFor(deps, ['commerce'], who); deps.vault.set('service:hal', 'test-secret-value-0001'); let r = await invoke(app, 'GET', '/internal/ops/commerce/orders'); eq(r.status, 401); const h = Object.fromEntries(Object.entries(signHeaders('hal', 'test-secret-value-0001', 'GET', '/internal/ops/commerce/orders', '')).map(([k, v]) => [k.toLowerCase(), v])); r = await invoke(app, 'GET', '/internal/ops/commerce/orders', { headers: h }); eq(r.status, 200); const h2 = Object.fromEntries(Object.entries(signHeaders('portal', 'test-secret-value-0001', 'GET', '/internal/ops/commerce/orders', '')).map(([k, v]) => [k.toLowerCase(), v])); r = await invoke(app, 'GET', '/internal/ops/commerce/orders', { headers: h2 }); eq(r.status, 401); const body = JSON.stringify({ ref: 'x' }); const h3 = Object.fromEntries(Object.entries(signHeaders('hal', 'test-secret-value-0001', 'POST', '/internal/ops/commerce/orders/nope/paid', body)).map(([k, v]) => [k.toLowerCase(), v])); r = await invoke(app, 'POST', '/internal/ops/commerce/orders/nope/paid', { headers: h3, body }); eq(r.status, 404, '서명 통과 뒤 본문 사용'); }); await test('pages: 상품·주문·결제·청구서 화면이 열리고 권한 없는 역할은 거부', async () => { const { app, deps, who } = await setup(); for (const p of ['/products', '/billing', '/order/new']) { const r = await call(app, 'GET', p); eq(r.status, 200, p); ok(r.text.includes('