import { describe, expect, it } from 'vitest'; import { txIdOf, lotIdOf, deviceHashOf } from '../src/core/ids.js'; import { dayKey, isPlausibleOccurredAt } from '../src/core/time.js'; import { DEFAULT_POLICY, FPI_AXES, weightsAreUsable } from '../src/core/config.js'; import { CONTRIBUTION_REASONS } from '../src/ledger/ledger.js'; import { expForLevel, levelOf, passportTierOf, titleOf } from '../src/core/level.js'; import { FPI_UNIT_FACTOR, rankWithTies } from '../src/ledger/fpi.js'; import { fnIapRefundWebhook, fnSubscriptionWebhook } from '../src/payment/iap.js'; import { fnTranslationMark, fnTranslationSubmit, isVisibleTarget, parseTranslationInput, translationRewardOf, translationSettleOf, } from '../src/account/translation.js'; import { publicNameOf } from '../src/core/names.js'; describe('공개 이름 (M-7)', () => { const user = (data: Record) => ({ get: (k: string) => data[k] }); it('닉네임만 쓰고 없거나 이메일 형태면 「팬」 — 공급자 실명은 쓰지 않는다', () => { expect(publicNameOf(user({ nickname: ' 아미보이 ', displayName: '김실명' }))).toBe('아미보이'); expect(publicNameOf(user({ displayName: '김실명' }))).toBe('팬'); expect(publicNameOf(user({ nickname: 'me@example.com' }))).toBe('팬'); expect(publicNameOf(user({ nickname: ' ' }))).toBe('팬'); expect(publicNameOf(null)).toBe('팬'); }); }); describe('번역 대상 공개 여부', () => { it('캠페인은 공개 상태만 · 나머지는 visibility visible(없으면 공개)', () => { for (const status of ['funding', 'success', 'fail', 'report']) { expect(isVisibleTarget('campaigns/c1', { status })).toBe(true); } for (const status of ['review', 'withdrawn', 'cancelled', 'rejected', undefined]) { expect(isVisibleTarget('campaigns/c1', { status })).toBe(false); } expect(isVisibleTarget('posts/p1', { visibility: 'visible' })).toBe(true); expect(isVisibleTarget('posts/p1', { visibility: 'hidden' })).toBe(false); expect(isVisibleTarget('questions/q1', {})).toBe(true); expect(isVisibleTarget('questions/q1', undefined)).toBe(false); }); }); describe('참여형 번역 등록 (FR-I18N-02 · B-63)', () => { const ok = { targetPath: 'questions/q1', locale: 'en', body: ' Hello ' }; it('정상 입력은 본문을 다듬어 받는다', () => { expect(parseTranslationInput(ok)).toEqual({ targetPath: 'questions/q1', locale: 'en', body: 'Hello' }); }); it('허용 콘텐츠 6종 경로만 받는다', () => { for (const targetPath of [ 'questions/q1', 'questions/q1/answers/a1', 'posts/p1', 'posts/p1/comments/c1', 'campaigns/c1', 'notices/n1', ]) { expect(parseTranslationInput({ ...ok, targetPath }).targetPath).toBe(targetPath); } }); it('허용 밖 경로는 존재 여부를 드러내지 않고 E-TRN-03 이다', () => { for (const targetPath of [ 'questions', 'users/alice', 'users/alice/wallet/main', 'questions/../users', 'campaigns/c1/likes/u1', 'posts/p1/likes/u1', 'config/policy', ]) { expect(() => parseTranslationInput({ ...ok, targetPath })) .toThrow(expect.objectContaining({ details: { code: 'E-TRN-03' } })); } }); it('지원 5종 밖의 언어 · 빈 본문 · 2000자 초과는 거부한다', () => { for (const bad of [ { ...ok, locale: 'zh-TW' }, { ...ok, body: ' ' }, { ...ok, body: 'a'.repeat(2001) }, ]) { expect(() => parseTranslationInput(bad)).toThrow(); } }); it('중복 문장 · 일일상한 초과는 등록하되 보상 0 이다', () => { const base = { pop: 5, dailyLimit: 10, todayCount: 0, duplicate: false }; expect(translationRewardOf(base)).toEqual({ rewardPop: 5, excludedReason: null }); expect(translationRewardOf({ ...base, duplicate: true })).toEqual({ rewardPop: 0, excludedReason: 'duplicate' }); expect(translationRewardOf({ ...base, todayCount: 10 })).toEqual({ rewardPop: 0, excludedReason: 'daily_limit' }); expect(translationRewardOf({ ...base, todayCount: 29 })).toEqual({ rewardPop: 0, excludedReason: 'daily_limit' }); }); it('일일상한의 3배부터는 등록 자체를 E-TRN-02 로 거부한다', () => { expect(() => translationRewardOf({ pop: 5, dailyLimit: 10, todayCount: 30, duplicate: false })) .toThrow(expect.objectContaining({ details: { code: 'E-TRN-02' } })); }); it('정산 — 유효가 최소치 이상이고 무효보다 많을 때만 확정, 못 채우면 최대 대기 뒤 해제', () => { const base = { valid: 0, invalid: 0, ageHours: 25, minValid: 1, maxPendingHours: 72 }; expect(translationSettleOf(base)).toBe('wait'); expect(translationSettleOf({ ...base, valid: 1 })).toBe('confirm'); expect(translationSettleOf({ ...base, valid: 1, invalid: 1 })).toBe('wait'); expect(translationSettleOf({ ...base, valid: 2, invalid: 1 })).toBe('confirm'); expect(translationSettleOf({ ...base, ageHours: 72 })).toBe('release'); expect(translationSettleOf({ ...base, valid: 3, invalid: 5, ageHours: 80 })).toBe('release'); }); it('보상 기본값은 원장 B-63 의 5 POP 이다', () => { expect(DEFAULT_POLICY.translationPop).toBe(5); }); it('로그인하지 않으면 등록·마킹 모두 데이터에 닿기 전에 거부한다', async () => { await expect(fnTranslationSubmit.run({ data: ok } as Parameters[0])) .rejects.toMatchObject({ details: { code: 'E-AUTH-03' } }); await expect(fnTranslationMark.run({ data: { entryId: 'e', valid: true } } as Parameters[0])) .rejects.toMatchObject({ details: { code: 'E-AUTH-03' } }); }); }); it('환불·구독 웹훅은 무인증·일반 사용자·조회 운영자의 호출을 데이터 접근 전에 거부한다', async () => { for (const fn of [fnIapRefundWebhook, fnSubscriptionWebhook]) { for (const auth of [undefined, { uid: 'user', token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) { await expect(fn.run({ data: {}, auth } as Parameters[0])) .rejects.toMatchObject({ details: { code: 'E-PERM-01' } }); } } }); /** 에뮬레이터 없이 도는 순수 함수 검증. */ describe('멱등 ID', () => { it('같은 키는 같은 txId 를 낸다', () => { expect(txIdOf('attend:u1:2026-09-01')).toBe(txIdOf('attend:u1:2026-09-01')); }); it('다른 키는 다른 txId 를 낸다', () => { expect(txIdOf('attend:u1:2026-09-01')).not.toBe(txIdOf('attend:u1:2026-09-02')); }); it('설계서가 정한 20자를 지킨다', () => { expect(txIdOf('any')).toHaveLength(20); }); it('lot 은 원장과 1:1 로 묶인다', () => { const tx = txIdOf('g:1'); expect(lotIdOf(tx)).toBe(`lot_${tx}`); }); it('단말 해시는 원값을 남기지 않는다', () => { const raw = 'device-serial-1234'; expect(deviceHashOf(raw)).not.toContain(raw); expect(deviceHashOf(raw)).toHaveLength(32); }); }); describe('일일 한도의 하루', () => { it('한국 시간대로 날짜를 끊는다', () => { // UTC 로는 08-31 이지만 한국은 이미 09-01 이다. expect(dayKey(new Date('2026-08-31T16:00:00Z'))).toBe('2026-09-01'); }); it('UTC 기준을 요구하면 그대로 따른다', () => { expect(dayKey(new Date('2026-08-31T16:00:00Z'), 0)).toBe('2026-08-31'); }); }); describe('시간 조작 판정', () => { const now = new Date('2026-09-01T00:00:00Z'); it('허용 오차 안이면 통과한다', () => { expect(isPlausibleOccurredAt(new Date('2026-09-01T00:10:00Z'), now)).toBe(true); }); it('미래로 크게 당긴 시각은 막는다', () => { expect(isPlausibleOccurredAt(new Date('2026-09-01T02:00:00Z'), now)).toBe(false); }); it('과거로 크게 미룬 시각도 막는다', () => { expect(isPlausibleOccurredAt(new Date('2026-08-31T20:00:00Z'), now)).toBe(false); }); }); describe('FPI 가중치', () => { it('초기값은 단순 합산이라 전부 1.0 이다', () => { // 정책 설계서 §P-03 「초기 MVP는 단순 합산」. 합이 1인 가중 평균이 아니다. for (const axis of FPI_AXES) { expect(DEFAULT_POLICY.fpiWeights[axis]).toBe(1); } expect(weightsAreUsable(DEFAULT_POLICY.fpiWeights)).toBe(true); }); it('Admin 이 가중치를 바꿔도 합을 1로 맞출 필요가 없다', () => { expect(weightsAreUsable({ bpm: 2, health: 1, referral: 0.5, community: 1, contribution: 3, })).toBe(true); }); it('음수 가중치는 걸러낸다 — 활동할수록 깎이는 랭킹이 된다', () => { expect(weightsAreUsable({ bpm: -1, health: 1, referral: 1, community: 1, contribution: 1, })).toBe(false); }); it('축이 빠지면 걸러낸다', () => { expect(weightsAreUsable( { bpm: 1, health: 1, referral: 1, community: 1 } as never, )).toBe(false); }); it('확정된 5지표를 그대로 쓴다', () => { expect([...FPI_AXES]) .toEqual(['bpm', 'health', 'referral', 'community', 'contribution']); }); }); describe('기여 집계 대상', () => { it('캠페인 후원·현상금·ZAP 행성 성장 기여를 기여로 센다', () => { // `ZAP_CONTRIBUTION` 은 별첨 B **B-23** 으로 더했다 — *ZAP 차감액은 누적 기여 POP 에 // 실제 값으로 반영*. Growth 진행도 가중(×1.5 이내)은 원장에 적용하지 않는다. // // `ECLIPSE_DEFENSE` 는 목록에 남아 있으나 **더 이상 쓰이지 않는다** — 이클립스 // 방어는 POP 을 소모하지 않는 공동 Shake 참여로 확정되었다(설계서 v1.1 §9-#5). // 과거 원장 행이 이 코드를 달고 있어 카탈로그에서 지우지 않는다. expect([...CONTRIBUTION_REASONS].sort()).toEqual( ['CAMPAIGN_FUND', 'ECLIPSE_DEFENSE', 'QNA_BOUNTY', 'ZAP_CONTRIBUTION'], ); }); }); describe('User Level · 칭호 · Passport — C-18 · B-65 · B-66', () => { it('신규 회원은 Lv.1 GLOW · SILVER 다', () => { expect(levelOf(0)).toBe(1); expect(titleOf(levelOf(0))).toBe('GLOW'); expect(passportTierOf(1)).toBe('SILVER'); }); it('레벨은 EXP 요구량 경계에서 정확히 오른다', () => { for (const level of [2, 10, 18, 30, 100, 150]) { expect(levelOf(expForLevel(level))).toBe(level); expect(levelOf(expForLevel(level) - 1)).toBe(level - 1); } // Admin Figma 표본 「Lv.18 · User EXP 128,450 · Next Level 14,550」 — S01 A안(243 × L^1.5). expect(levelOf(128_450)).toBe(18); expect(expForLevel(18)).toBe(124_450); expect(expForLevel(19)).toBe(143_010); expect(expForLevel(19) - 128_450).toBe(14_560); }); it('칭호 경계 9/10 · 29/30 · 59/60 · 99/100, 100 이후에도 숫자가 오른다', () => { expect([9, 10, 29, 30, 59, 60, 99, 100].map(titleOf)).toEqual( ['GLOW', 'SPARK', 'SPARK', 'BEAM', 'BEAM', 'STARLIGHT', 'STARLIGHT', 'NOVA']); expect(titleOf(150)).toBe('NOVA'); expect(levelOf(expForLevel(150) + 1)).toBe(150); }); it('누적 기여 POP 문턱으로 승급하지 않는다', () => { // v0.3.0 의 3,000 POP = SPARK 는 기각됐다. EXP 3,000 은 Lv.4 GLOW 다. expect(titleOf(levelOf(3_000))).toBe('GLOW'); }); it('Passport 는 Gold Lv.30+ · Diamond Lv.70+ 이고 강등하지 않는다', () => { expect(passportTierOf(29)).toBe('SILVER'); expect(passportTierOf(30)).toBe('GOLD'); expect(passportTierOf(70)).toBe('DIAMOND'); expect(passportTierOf(1, 'DIAMOND')).toBe('DIAMOND'); expect(passportTierOf(40, 'DIAMOND')).toBe('DIAMOND'); expect(passportTierOf(75, 'GOLD')).toBe('DIAMOND'); }); }); describe('FPI 공동 순위 — C-19', () => { const rows = [ { id: 'c', v: 10 }, { id: 'b', v: 5 }, { id: 'a', v: 10 }, { id: 'd', v: 10.4 }, ]; const rank = (input: typeof rows) => rankWithTies(input, (r) => r.v, (r) => r.id).map(({ item, rank }) => `${item.id}:${rank}`); it('동점은 같은 순위, 다음은 건너뛴다 · 반올림이 같아도 실값 순', () => { expect(rank(rows)).toEqual(['d:1', 'a:2', 'c:2', 'b:4']); expect(rank(rows.filter((r) => r.id !== 'd'))).toEqual(['a:1', 'c:1', 'b:3']); }); it('입력 순서가 바뀌어도 동점 배치가 고정된다', () => { expect(rank([...rows].reverse())).toEqual(rank(rows)); }); it('환산계수는 S01 확정 전 1.0 이다', () => { expect(FPI_UNIT_FACTOR).toBe(1); }); });