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

79 lines
11 KiB
JavaScript
Raw Normal View History

/** 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();