상품·주문·수동 결제 승인·권리·청구·축소·해지·AI Wallet 표시·사용량 배정 화면·경고 엔진 (§6·§13·§27)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:26:35 +00:00
co-authored by Claude Fable 5.1
parent c90ac27596
commit c397452acd
21 changed files with 1147 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
/** 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();
+78
View File
@@ -0,0 +1,78 @@
/** test/usage.test.mjs — 사용량 요약·배정 변경(동시성·사용량 미만·지연)·경고 엔진(구간별 1회·사건 묶기·100% 제한·초과 사용·KST 월 초기화)·운영자 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 * as quota from '../lib/quota.mjs';
import * as cat from '../portal/commerce/catalog.mjs';
import * as ord from '../portal/commerce/orders.mjs';
import { evaluate } from '../portal/usage/warnings.mjs';
import { isLimited, limitInfo, listLimits } from '../portal/usage/limits.mjs';
import { monthStart } from '../lib/kst.mjs';
import { newId } from '../lib/crypto.mjs';
const GB = 1024 ** 3;
async function setup() {
const deps = await makeDeps({ opsAuthBypass: true, autoSeedCatalog: true }); const who = seedGroup(deps); const app = await appFor(deps, ['commerce', 'usage'], who); const store = deps.store;
const pv = cat.publishedProducts(store).find((p) => p.product_id === 'basic');
const o = ord.createOrder(store, { groupId: who.group.id, userId: who.user.id, userVerified: true, productVersionId: pv.id, acceptTerms: true, items: [{ optionId: 'extra_disk', qty: 1 }] }).order; ord.markPaid(store, o.id, { by: 't' }); ord.approveOrder(store, o.id, { by: 't' });
const mk = (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, who.group.id, name, now, now); return id; };
return { deps, who, app, store, s1: mk('A'), s2: mk('B'), gid: who.group.id };
}
const usage = (store, gid, res, sid, used, extra = {}) => quota.recordUsage(store, { groupId: gid, resource: res, subjectType: 'site', subjectId: sid, used, measuredAt: new Date().toISOString(), ...extra });
await test('summary: 구매·배정·예약·사용·미배정과 상품정책·다음 KST 초기화·마지막 계측', async () => {
const { app, store, gid, s1 } = await setup(); quota.setAllocation(store, { groupId: gid, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 4 * GB }); usage(store, gid, 'web_disk', s1, 1 * GB);
quota.reserve(store, { groupId: gid, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s1, reason: '최초 공개 배포' });
const r = await call(app, 'GET', '/api/usage'); eq(r.status, 200); const d = r.json; eq(d.product.name, '베이직'); eq(d.product.warnLevels, [70, 80, 90, 95]); ok(d.nextResetAt > new Date().toISOString());
const wd = d.resources.find((x) => x.resource === 'web_disk'); eq([wd.purchased, wd.allocated, wd.unallocated, wd.used], [10 * GB, 4 * GB, 6 * GB, 1 * GB]); eq(d.resources.find((x) => x.resource === 'sites').reserved, 1);
eq(d.allocations[0].subjectName, 'A'); eq(d.allocations[0].stateLabel, '사용 중'); eq(d.allocations[0].percent, 25); eq(d.reservations[0].stateLabel, '예약됨'); eq(d.stale, false); ok(d.lastMeasuredText.endsWith('KST'));
const pg = await call(app, 'GET', '/usage'); eq(pg.status, 200); ok(pg.text.includes('/ui/usage/usage.js'));
});
await test('allocate: 화면 수치(구매·내 배정·다른 서비스·예약·미배정·변경 후 최대), 마지막 단위 동시 요청 중 하나만 성공, 사용량 미만 거부, 월간 누적 미만 거부, 지연 시 증가 차단', async () => {
const { app, store, gid, s1, s2 } = await setup();
let r = await call(app, 'GET', `/api/usage/allocate/site:${s1}`); eq(r.status, 200); const wd = r.json.resources.find((x) => x.resource === 'web_disk'); eq([wd.purchased, wd.mine, wd.others, wd.unallocated, wd.max], [10 * GB, 0, 0, 10 * GB, 10 * GB]); eq(wd.mineState, '사용 가능');
r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_disk', amount: 6 * GB }); eq(r.status, 200); eq(r.json.after, 6 * GB);
r = await call(app, 'GET', `/api/usage/allocate/site:${s2}`); eq(r.json.resources.find((x) => x.resource === 'web_disk').others, 6 * GB);
// 남은 4GB 를 두 요청이 동시에 요청 → 하나만 성공
const [a, b] = await Promise.all([call(app, 'POST', `/api/usage/allocate/site:${s2}`, { resource: 'web_disk', amount: 4 * GB }), call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_disk', amount: 10 * GB })]);
eq([a.status, b.status].sort(), [200, 409]); const failed = a.status === 409 ? a : b; eq(failed.json.error, 'quota_insufficient'); ok(failed.json.detail.shortage > 0); eq(quota.figures(store, gid, 'web_disk').allocated, 10 * GB);
// 사용량 미만 축소 거부
usage(store, gid, 'web_disk', s1, 5 * GB); r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_disk', amount: 4 * GB }); eq(r.status, 409); eq(r.json.error, 'allocation_below_usage'); eq(r.json.detail.cleanupNeeded, 1 * GB);
// 월간 자원: 이번 달 누적보다 작게 불가
quota.setAllocation(store, { groupId: gid, resource: 'web_out', subjectType: 'site', subjectId: s1, amount: 20 * GB }); usage(store, gid, 'web_out', s1, 15 * GB, { periodStart: monthStart() }); r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_out', amount: 10 * GB }); eq(r.json.error, 'allocation_below_usage');
// 지연: 계측이 오래되면 증가 차단, 축소는 허용
store.run('UPDATE quota_usage SET measured_at=? WHERE subject_id=? AND resource=?', new Date(Date.now() - 3 * 3600000).toISOString(), s1, 'web_out'); r = await call(app, 'GET', `/api/usage/allocate/site:${s1}`); ok(r.json.resources.find((x) => x.resource === 'web_out').stale);
r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_out', amount: 30 * GB }); eq(r.json.error, 'usage_stale'); r = await call(app, 'POST', `/api/usage/allocate/site:${s1}`, { resource: 'web_out', amount: 16 * GB }); eq(r.status, 200);
// 다른 그룹 대상·잘못된 대상
r = await call(app, 'GET', '/api/usage/allocate/site:nope'); eq(r.status, 404); r = await call(app, 'GET', '/api/usage/allocate/bogus'); eq(r.status, 400);
// 역할: member 는 배정 변경 불가
const { deps, who } = await (async () => { const d2 = await makeDeps({ opsAuthBypass: true, autoSeedCatalog: true }); return { deps: d2, who: seedGroup(d2, { role: 'member' }) }; })(); const app2 = await appFor(deps, ['commerce', 'usage'], who); r = await call(app2, 'POST', '/api/usage/allocate/site:x', { resource: 'web_disk', amount: 1 }); eq(r.status, 403);
});
await test('warnings: 구간별 알림 1회·같은 구간은 묶음·상위 구간으로 올라가면 하위 종료·100% 제한 플래그·초과 사용 허용 시 제한 없음·내려가면 해제', async () => {
const { deps, store, gid, s1, who } = await setup(); quota.setAllocation(store, { groupId: gid, resource: 'web_disk', subjectType: 'site', subjectId: s1, amount: 10 * GB });
const notifs = () => store.all("SELECT title, state, count, incident_key, severity, mandatory FROM notifications WHERE category='usage' ORDER BY first_at");
eq(evaluate(deps, gid).evaluated, 0, '계측 전에는 평가하지 않음');
usage(store, gid, 'web_disk', s1, 7.5 * GB); let r = evaluate(deps, gid); eq(r.notified, [{ resource: 'web_disk', subjectId: s1, level: 70 }]); eq(notifs().length, 1); eq(notifs()[0].severity, 'info');
r = evaluate(deps, gid); eq(r.notified.length, 0, '같은 구간 재평가는 알림 없음'); eq(notifs().length, 1);
usage(store, gid, 'web_disk', s1, 8.5 * GB); r = evaluate(deps, gid); eq(r.notified[0].level, 80); eq(notifs().find((n) => n.incident_key.endsWith(':70')).state, 'resolved'); eq(notifs().find((n) => n.incident_key.endsWith(':80')).severity, 'warning');
usage(store, gid, 'web_disk', s1, 9.1 * GB); evaluate(deps, gid); const n90 = notifs().find((n) => n.incident_key.endsWith(':90')); eq(n90.mandatory, 1); ok(n90.title.includes('긴급'));
usage(store, gid, 'web_disk', s1, 9.6 * GB); evaluate(deps, gid); ok(notifs().find((n) => n.incident_key.endsWith(':95')).title.includes('곧 제한'));
usage(store, gid, 'web_disk', s1, 10 * GB); r = evaluate(deps, gid); eq(r.limited.length, 1); ok(isLimited(store, 'web_disk', 'site', s1)); const li = limitInfo(store, 'web_disk', 'site', s1); eq(li.level, 100); ok(li.semantics.blocked.includes('새 배포')); ok(notifs().find((n) => n.incident_key.endsWith(':100')).title.includes('제한 적용')); eq(store.get("SELECT COUNT(*) AS n FROM audit_log WHERE action='usage.limit'").n, 1);
eq(listLimits(store, gid).length, 1);
// 초과 사용 허용(월간 자원만 상품에 있음): web_out 100% 에서 제한 없음
quota.setAllocation(store, { groupId: gid, resource: 'web_out', subjectType: 'site', subjectId: s1, amount: 10 * GB }); ord.optInOverage(store, { groupId: gid, userId: who.user.id, resource: 'web_out', capAmount: 3000, periodStart: new Date(Date.now() - 1000).toISOString(), periodEnd: new Date(Date.now() + 86400000 * 20).toISOString() });
usage(store, gid, 'web_out', s1, 10 * GB, { periodStart: monthStart() }); r = evaluate(deps, gid); ok(!isLimited(store, 'web_out', 'site', s1)); ok(limitInfo(store, 'web_out', 'site', s1).overage); ok(notifs().find((n) => n.incident_key.includes('web_out') && n.incident_key.endsWith(':100')).title.includes('초과 사용'));
ord.revokeOverage(store, { groupId: gid, userId: who.user.id, resource: 'web_out' }); evaluate(deps, gid); ok(isLimited(store, 'web_out', 'site', s1), '철회 즉시 제한');
// 사용량이 내려가면 제한 해제·사건 종료
usage(store, gid, 'web_disk', s1, 5 * GB); r = evaluate(deps, gid); ok(!isLimited(store, 'web_disk', 'site', s1)); eq(notifs().filter((n) => n.incident_key.includes('web_disk') && n.state === 'open').length, 1); ok(notifs().find((n) => n.title.includes('제한 해제'))); eq(store.get("SELECT level FROM usage_limits WHERE resource='web_disk'").level, 0);
});
await test('warnings: 월간 자원은 KST 월이 바뀌면 사용량 0 으로(초기화), 운영자 API', async () => {
const { deps, app, store, gid, s1 } = await setup(); quota.setAllocation(store, { groupId: gid, resource: 'mail_msgs', subjectType: 'site', subjectId: s1, amount: 1000 });
const lastMonth = new Date(Date.parse(monthStart()) - 1000).toISOString();
usage(store, gid, 'mail_msgs', s1, 1000, { periodStart: monthStart(lastMonth) }); let r = evaluate(deps, gid); eq(r.limited.length, 0); eq(store.get("SELECT level FROM usage_limits WHERE resource='mail_msgs'").level, 0, '지난 달 계측은 0 으로');
usage(store, gid, 'mail_msgs', s1, 1000, { periodStart: monthStart() }); r = evaluate(deps, gid); eq(r.limited.length, 1); ok(limitInfo(store, 'mail_msgs', 'site', s1).semantics.blocked.includes('새 발신'));
r = await invoke(app, 'GET', '/internal/ops/usage/overview'); eq(r.status, 200); eq(r.json.groups.length, 1); eq(r.json.groups[0].limits, 1);
r = await invoke(app, 'GET', '/internal/ops/usage/limits'); eq(r.json.limits[0].resource, 'mail_msgs'); r = await invoke(app, 'POST', '/internal/ops/usage/evaluate'); eq(r.json.results.length, 1);
r = await call(app, 'POST', '/api/usage/evaluate'); eq(r.status, 200); r = await call(app, 'GET', '/api/usage/limits'); eq(r.json.limits.length, 1); ok(r.json.semantics.web_out);
});
done();