Files
fanuzu/app/firebase/functions/test/pure.test.ts
T

296 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, titleOf } from '../src/core/level.js';
import { badgeStageOf, BADGE_STAGE_THRESHOLDS, DEFAULT_TIER_RULES, tierOf } from '../src/account/passport.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<string, unknown>) => ({ 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<typeof fnTranslationSubmit.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
await expect(fnTranslationMark.run({ data: { entryId: 'e', valid: true } } as Parameters<typeof fnTranslationMark.run>[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<typeof fn.run>[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(tierOf(DEFAULT_TIER_RULES, { userLevel: 1, activeDays: 0, badgeStages: [], contributedPop: 0 })).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 Tier 는 팬덤별 규칙(Level · 활동기간 · Badge · 누적기여)을 모두 채워야 오르고 강등하지 않는다', () => {
const base = { userLevel: 30, activeDays: 30, badgeStages: [1], contributedPop: 0 };
expect(tierOf(DEFAULT_TIER_RULES, base)).toBe('GOLD');
// 하나라도 모자라면 그대로 — Level 만으로 오르지 않는다(상세정의 4장 · 10/3 #10).
expect(tierOf(DEFAULT_TIER_RULES, { ...base, userLevel: 29 })).toBe('SILVER');
expect(tierOf(DEFAULT_TIER_RULES, { ...base, activeDays: 29 })).toBe('SILVER');
expect(tierOf(DEFAULT_TIER_RULES, { ...base, badgeStages: [] })).toBe('SILVER');
const diamond = { userLevel: 70, activeDays: 180, badgeStages: [3, 3], contributedPop: 100_000 };
expect(tierOf(DEFAULT_TIER_RULES, diamond)).toBe('DIAMOND');
expect(tierOf(DEFAULT_TIER_RULES, { ...diamond, contributedPop: 99_999 })).toBe('GOLD');
expect(tierOf(DEFAULT_TIER_RULES, { ...base, userLevel: 1 }, 'DIAMOND')).toBe('DIAMOND');
// 운영값이 규칙을 끄면 그 Tier 로 오르지 않는다.
expect(tierOf(DEFAULT_TIER_RULES.map((r) => ({ ...r, enabled: false })), diamond)).toBe('SILVER');
});
it('활동 Badge 는 7단계 10/100/500/1,500/4,000/10,000/30,000(상세정의 4장)', () => {
expect(BADGE_STAGE_THRESHOLDS).toEqual([10, 100, 500, 1_500, 4_000, 10_000, 30_000]);
expect([0, 9, 10, 99, 100, 1_500, 29_999, 30_000, 1e9].map((n) => badgeStageOf(BADGE_STAGE_THRESHOLDS, n)))
.toEqual([0, 0, 1, 1, 2, 4, 6, 7, 7]);
});
});
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);
});
});