Files
AriWeb-Suite/test/commerce.test.mjs
T

121 lines
18 KiB
JavaScript
Raw Normal View History

/** 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('<html')); }
const viewer = { ...who, membership: { ...who.membership, role: 'viewer' } }; const app2 = await appFor(deps, ['commerce'], viewer); const r = await call(app2, 'POST', '/api/commerce/orders', { productVersionId: 'x', acceptTerms: true }); eq(r.status, 403); eq(r.json.error, 'forbidden'); ok(r.json.detail.requiredRole);
});
done();