Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
58 lines
7.5 KiB
JavaScript
58 lines
7.5 KiB
JavaScript
/** test/dashboard.test.mjs — 대시보드 요약·주의 항목 순서·최근 활동 / 알림 센터 API / 스케줄러 기본 작업. */
|
|
import { test, eq, ok, done } from '../lib/test.mjs';
|
|
import { makeDeps, appFor, seedGroup, call } from './helpers.mjs';
|
|
import { grantEntitlement, reserve, commit, recordUsage, setAllocation } from '../lib/quota.mjs';
|
|
import { notify } from '../lib/notify.mjs';
|
|
import { audit } from '../lib/audit.mjs';
|
|
import { newId } from '../lib/crypto.mjs';
|
|
|
|
const deps = await makeDeps({ mailer: { send: async () => ({ ok: true }), mode: () => 'test' } });
|
|
const who = seedGroup(deps); const other = seedGroup(deps, { email: 'o@example.test', name: '남' });
|
|
deps.jobs.register('deploy.site', { title: '배포', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { if (job.subject.fail) throw new Error('boom'); ctx.waitExternal('외부 확인'); } });
|
|
const app = await appFor(deps, ['auth', 'dashboard', 'notifications'], who);
|
|
const g = who.group.id; const now = new Date().toISOString();
|
|
|
|
await test('요약: 사이트 수(구매·사용 중·예약)·초안·건강·배포 방식·도메인·메일·백업·작업·비용', async () => {
|
|
grantEntitlement(deps.store, { groupId: g, resource: 'sites', amount: 3 }); grantEntitlement(deps.store, { groupId: g, resource: 'web_disk', amount: 1000 });
|
|
const s1 = newId('s_'); const s2 = newId('s_');
|
|
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s1, g, 'A', 'live', 'managed', 'ok', now, now);
|
|
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', s2, g, 'B', 'draft', null, 'unknown', now, now);
|
|
deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, health, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('s_'), other.group.id, 'X', 'live', 'external', 'down', now, now);
|
|
commit(deps.store, reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s1 }).id); reserve(deps.store, { groupId: g, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s2 });
|
|
deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'a.example.test', s1, 'verified', 'ok', 'ok', now, now);
|
|
deps.store.run('INSERT INTO domains(id, group_id, name, site_id, ownership_state, dns_state, cert_state, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('d_'), g, 'b.example.test', s1, 'pending', 'pending', 'none', now, now);
|
|
deps.store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', newId('ms_'), g, 'site_bound', s1, 'A 메일', 'done', now, now);
|
|
deps.store.run('INSERT INTO backup_options(id, site_id, group_id, status, schedule_json, retention_json, consecutive_failures, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', newId('bo_'), s1, g, 'active', '{}', '{}', 3, now, now);
|
|
deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-1', '[]', 10000, 1000, 11000, 'awaiting_payment', now, now);
|
|
deps.store.run('INSERT INTO orders(id, group_id, order_no, items_json, amount, tax, total, status, approved_at, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', newId('o_'), g, 'AW-2', '[]', 20000, 2000, 22000, 'approved', now, now, now);
|
|
deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subjectType: 'site', subjectId: s1 }); await deps.jobs.drain();
|
|
const r = await call(app, 'GET', '/api/dashboard'); eq(r.status, 200); const s = r.json.summary;
|
|
eq([s.sites.purchased, s.sites.inUse, s.sites.reserved, s.sites.unallocated, s.sites.live], [3, 1, 1, 1, 1]); eq(s.drafts, 1); eq(s.health, { ok: 1, warn: 0, bad: 0 }); eq(s.deploy, { managed: 1, external: 0 });
|
|
eq(s.domains, { total: 2, action: 1 }); eq(s.mail, { services: 1, mailboxes: 0 }); eq(s.backup, 1); eq(s.jobs, [{ kind: 'deploy', label: '배포', n: 1 }]); eq([s.cost.estimated, s.cost.awaiting], [22000, 11000]);
|
|
});
|
|
await test('주의 항목: 필수 알림 → 결제 대기 → 도메인 → 사용량 → 실패 작업 → 백업 순, 각각 링크 / 다른 그룹 항목 없음', async () => {
|
|
notify(deps.store, { groupId: g, category: 'security', severity: 'critical', mandatory: true, title: '보안 경고', link: '/account/security' });
|
|
notify(deps.store, { groupId: other.group.id, category: 'security', severity: 'critical', mandatory: true, title: '남의 경고', link: '/x' });
|
|
recordUsage(deps.store, { groupId: g, resource: 'web_disk', subjectType: 'site', subjectId: 'any', used: 960 });
|
|
deps.jobs.enqueue({ kind: 'deploy.site', groupId: g, subject: { fail: true }, subjectType: 'site', subjectId: 'sX' }); await deps.jobs.drain();
|
|
const r = await call(app, 'GET', '/api/dashboard'); const a = r.json.attention;
|
|
eq(a[0].text, '보안 경고'); eq(a[0].href, '/account/security'); ok(!a.some((x) => x.text === '남의 경고'));
|
|
const kinds = a.map((x) => x.kind); ok(kinds.indexOf('notification') < kinds.indexOf('order')); ok(a.find((x) => x.kind === 'usage' && /95%/.test(x.text) && x.severity === 'critical')); ok(a.find((x) => x.kind === 'job' && x.severity === 'critical')); ok(a.find((x) => x.kind === 'backup' && /3회/.test(x.text))); ok(a.find((x) => x.kind === 'domain' && /소유 확인/.test(x.text)));
|
|
ok(a.every((x) => x.href && x.href.startsWith('/')), '모든 항목에 조치 링크');
|
|
});
|
|
await test('최근 활동: 사용자/자동 구분, 인증 로그 제외 / 알림 API: 필터·읽음·사건 반복·필수', async () => {
|
|
audit(deps.store, { actorType: 'user', actorId: who.user.id, actorLabel: who.user.email, groupId: g, action: 'deploy.start' }); audit(deps.store, { actorType: 'system', groupId: g, action: 'backup.run', automatic: true }); audit(deps.store, { actorType: 'user', actorId: who.user.id, groupId: g, action: 'auth.login' });
|
|
let r = await call(app, 'GET', '/api/dashboard'); const rec = r.json.recent; eq(rec.length, 2); eq(rec[0].automatic, true); eq(rec[1].automatic, false);
|
|
notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90%' }); notify(deps.store, { groupId: g, category: 'usage', incidentKey: 'u1', title: '90% 다시' });
|
|
r = await call(app, 'GET', '/api/notifications?category=usage'); eq(r.json.items.length, 1); eq(r.json.items[0].count, 2); ok(r.json.unread >= 2);
|
|
r = await call(app, 'GET', '/api/notifications'); ok(r.json.items.find((n) => n.mandatory && n.title === '보안 경고')); ok(!r.json.items.find((n) => n.title === '남의 경고'));
|
|
const id = r.json.items[0].id; r = await call(app, 'POST', `/api/notifications/${id}/read`); eq(r.json.changed, 1); r = await call(app, 'GET', '/api/notifications?unread=1'); ok(!r.json.items.find((n) => n.id === id));
|
|
r = await call(app, 'POST', '/api/notifications/read-all'); r = await call(app, 'GET', '/api/notifications?unread=1'); eq(r.json.items.length, 0);
|
|
});
|
|
await test('스케줄러: 만료 예약 자동 반환 + 알림, 작업 목록', async () => {
|
|
deps.store.run('UPDATE quota_reservations SET expires_at=? WHERE state=\'reserved\'', new Date(0).toISOString());
|
|
const r = await deps.scheduler.runNow('quota.expire'); eq(r.result, 1); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE group_id=? AND incident_key LIKE \'reservation:%\'', g));
|
|
ok(deps.scheduler.list().map((t) => t.name).includes('sessions.purge'));
|
|
});
|
|
done();
|