Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
@@ -5,7 +5,7 @@ import { fnAdminWrite } from '../src/admin/admin.js';
/**
* `fn-admin-write` 권한 경계 — firestore.rules 에서 master 전용인 문서를 ops 가 이 함수로
* 우회해 쓰지 못한다. 거부도 `adminActionLogs` 에 남는다(FR-ADM-01).
* 우회해 쓰지 못한다(S05 조사). 거부도 `adminActionLogs` 에 남는다(FR-ADM-01).
*/
const run = (role: string | undefined, targetPath: string,
patch: Record<string, unknown> = { attendPop: 999 }) =>
@@ -46,7 +46,7 @@ it('ops 는 master 전용이 아닌 문서는 그대로 쓴다 · 역할 없는
expect(await denials('admin-none')).toBe(1);
});
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만', async () => {
it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만(리뷰 H3)', async () => {
const denied = (role: string, path: string, patch?: Record<string, unknown>) =>
expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
await denied('master', 'fandoms/f1/fpiEvents/e1');
@@ -0,0 +1,367 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { bootAdmin, shutdownAdmin } from './support.js';
import {
candidateDocId, fnBriefingIngest, fnBriefingPublish, validateCandidate,
} from '../src/briefing/briefing.js';
import { dayKey } from '../src/core/time.js';
import { fandomTopic, notifyEclipseWarning } from '../src/notify/push.js';
/**
* AI Daily Briefing 검증 — 정책 원장 B-47 · B-48 · B-75.
*
* 보는 것은 셋이다.
* - **수신이 5 요소를 강제하는가**(B-75 「메타 누락 건은 Draft 단계에서 배제」)
* - **수신이 draft 로만 끝나는가**(B-47 「AI Draft 자동게시 금지」)
* - **승인 권한이 함수에 실제로 붙어 있는가**
*
* 실제 FCM 발송은 확인하지 않는다 — 배포 전이고 에뮬레이터에 FCM 이 없다.
*/
const FANDOM = 'test-brief-fandom';
/** 5 요소를 모두 갖춘 정상 후보. 각 검증이 여기서 한 값만 빼서 쓴다. */
function candidate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
fandomId: FANDOM,
section: 'News',
title: '데뷔 10주년 단독 콘서트 확정',
sourceName: '연합뉴스',
sourceAt: '2026-09-14T01:20:00Z',
sourceUrl: 'https://example.com/news/1',
articleUrl: 'https://example.com/news/1',
aiSummary: '서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다.',
...overrides,
};
}
const req = (role: string | undefined, data: unknown) => ({
auth: role ? { uid: 'admin-1', token: { role } } : undefined,
data,
}) as unknown as CallableRequest;
/** 섹션별 rank 목록. 정렬해 두어야 비교가 흔들리지 않는다. */
function ranksBySection(
docs: FirebaseFirestore.QueryDocumentSnapshot[],
): Record<string, number[]> {
const bySection: Record<string, number[]> = {};
for (const doc of docs) {
const section = doc.get('section') as string;
bySection[section] = [...(bySection[section] ?? []), doc.get('rank') as number];
}
for (const key of Object.keys(bySection)) bySection[key]!.sort((a, b) => a - b);
return bySection;
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
const db = getFirestore();
const briefs = await db.collection('briefings')
.where('fandomId', '==', FANDOM).get();
await Promise.all(briefs.docs.map((d) => d.ref.delete()));
// 작업 로그도 지운다. 남겨 두면 게시 검증의 로그 건수가 회차마다 늘어난다.
const logs = await db.collection('adminActionLogs')
.where('targetType', '==', 'briefing').get();
await Promise.all(logs.docs.map((d) => d.ref.delete()));
const eclipses = await db.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
await Promise.all(eclipses.docs.map((d) => d.ref.delete()));
await db.doc(`fandoms/${FANDOM}`).set({ name: '테스트행성', stage: 1 });
});
describe('B-75 5 요소 검증', () => {
it('다 갖춘 후보를 통과시킨다', () => {
expect(validateCandidate(candidate()).ok).toBe(true);
});
it('5 요소 중 하나만 빠져도 거부한다', () => {
for (const key of [
'title', 'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary',
]) {
const result = validateCandidate(candidate({ [key]: undefined }));
expect(result.ok, key).toBe(false);
if (!result.ok) expect(result.reason, key).toContain(key);
}
});
it('빈 문자열과 공백을 누락으로 본다 — 수집기가 흔히 채워 보낸다', () => {
for (const value of ['', ' ']) {
expect(validateCandidate(candidate({ sourceName: value })).ok).toBe(false);
}
});
it('빠진 것을 한 번에 모아서 알린다', () => {
const result = validateCandidate(
candidate({ sourceUrl: undefined, aiSummary: undefined }),
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toContain('sourceUrl');
expect(result.reason).toContain('aiSummary');
}
});
it('섹션은 News 와 SNS 만 받는다', () => {
expect(validateCandidate(candidate({ section: 'SNS' })).ok).toBe(true);
for (const bad of ['activity', 'news', '', undefined]) {
expect(validateCandidate(candidate({ section: bad })).ok, String(bad)).toBe(false);
}
});
it('시각 형식과 URL 형태까지 본다', () => {
expect(validateCandidate(candidate({ sourceAt: '어제' })).ok).toBe(false);
expect(validateCandidate(candidate({ articleUrl: 'javascript:alert(1)' })).ok)
.toBe(false);
expect(validateCandidate(candidate({ sourceUrl: 'example.com/1' })).ok).toBe(false);
});
it('같은 원본 URL 은 같은 문서 ID 로 모인다', () => {
const a = validateCandidate(candidate());
const b = validateCandidate(candidate({ title: '제목만 바뀐 같은 기사' }));
expect(a.ok && b.ok).toBe(true);
if (a.ok && b.ok) {
expect(candidateDocId(a.value, '2026-09-14'))
.toBe(candidateDocId(b.value, '2026-09-14'));
}
});
});
describe('수신 — fn-briefing-ingest (export 직접 호출)', () => {
it('통과 건만 저장하고 거부 사유를 함께 돌려준다', async () => {
const result = await fnBriefingIngest.run(req('ops', {
items: [
candidate(),
candidate({ section: 'SNS', sourceUrl: 'https://example.com/sns/1' }),
candidate({ sourceName: undefined, sourceUrl: 'https://example.com/news/2' }),
],
}));
expect(result.accepted).toBe(2);
expect(result.rejected).toHaveLength(1);
expect(result.rejected[0]!.index).toBe(2);
expect(result.rejected[0]!.reason).toContain('sourceName');
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(2);
});
it('수신은 언제나 draft 로 끝난다 — 자동 게시 0 건', async () => {
await fnBriefingIngest.run(req('master', { items: [candidate()] }));
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(1);
const doc = docs.docs[0]!;
expect(doc.get('status')).toBe('draft');
expect(doc.get('aiGenerated')).toBe(true);
expect(doc.get('dayKey')).toBe(dayKey(new Date()));
// B-75 5 요소가 문서에 전건 남아 있어야 화면이 그릴 수 있다.
for (const field of [
'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary',
]) {
expect(doc.get(field), field).toBeTruthy();
}
});
it('rank 를 섹션마다 따로 매긴다', async () => {
await fnBriefingIngest.run(req('ops', {
items: [
candidate({ sourceUrl: 'https://example.com/n/1' }),
candidate({ sourceUrl: 'https://example.com/n/2' }),
candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }),
],
}));
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(ranksBySection(docs.docs)).toEqual({ News: [1, 2], SNS: [1] });
});
it('같은 회차를 두 번 받아도 문서가 늘지 않는다', async () => {
const items = [candidate()];
await fnBriefingIngest.run(req('ops', { items }));
await fnBriefingIngest.run(req('ops', { items }));
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(1);
});
it('빈 목록과 권한 없는 호출을 끊는다', async () => {
await expect(fnBriefingIngest.run(req('ops', { items: [] })))
.rejects.toThrowError('수신할 브리핑 항목이 없습니다.');
await expect(fnBriefingIngest.run(req('viewer', { items: [candidate()] })))
.rejects.toThrowError('권한이 없습니다.');
await expect(fnBriefingIngest.run(req(undefined, { items: [candidate()] })))
.rejects.toThrowError('로그인이 필요합니다.');
});
it('전건 거부면 저장이 없다', async () => {
const result = await fnBriefingIngest.run(req('ops', {
items: [candidate({ aiSummary: undefined })],
}));
expect(result.accepted).toBe(0);
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.size).toBe(0);
});
});
describe('승인 — fn-briefing-publish (export 직접 호출)', () => {
/** 당일 초안 3 건(News 2 · SNS 1)을 수신 경로로 심는다. */
async function seedDrafts(): Promise<void> {
await fnBriefingIngest.run(req('ops', {
items: [
candidate({ sourceUrl: 'https://example.com/n/1' }),
candidate({ sourceUrl: 'https://example.com/n/2' }),
candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }),
],
}));
}
it('viewer 는 게시하지 못하고 초안이 그대로 남는다', async () => {
await seedDrafts();
await expect(
fnBriefingPublish.run(req('viewer', {
fandomId: FANDOM, dayKey: dayKey(new Date()),
})),
).rejects.toThrowError('권한이 없습니다.');
const docs = await getFirestore().collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.docs.every((d) => d.get('status') === 'draft')).toBe(true);
});
it('로그인하지 않은 호출은 끊는다', async () => {
await expect(
fnBriefingPublish.run(req(undefined, {
fandomId: FANDOM, dayKey: dayKey(new Date()),
})),
).rejects.toThrowError('로그인이 필요합니다.');
});
it('초안이 없으면 게시할 것이 없다고 알린다', async () => {
await expect(
fnBriefingPublish.run(req('master', {
fandomId: FANDOM, dayKey: dayKey(new Date()),
})),
).rejects.toThrowError('게시할 브리핑 초안이 없습니다.');
});
it('master 는 게시하고 섹션별 건수까지 로그에 남긴다', async () => {
const db = getFirestore();
const day = dayKey(new Date());
await seedDrafts();
const result = await fnBriefingPublish.run(
req('master', { fandomId: FANDOM, dayKey: day }),
);
expect(result.published).toBe(3);
expect(result.bySection).toEqual({ News: 2, SNS: 1 });
// FCM 자격 증명이 없는 환경이라 발송은 실패한다. **게시는 되돌리지 않는다** —
// 관리자가 본 결과와 사용자가 보는 화면이 갈리지 않게 한 설계 그대로다.
expect(result.pushed).toBe(false);
const docs = await db.collection('briefings')
.where('fandomId', '==', FANDOM).get();
expect(docs.docs.every((d) => d.get('status') === 'published')).toBe(true);
expect(docs.docs.every((d) => d.get('publishedBy') === 'admin-1')).toBe(true);
const logs = await db.collection('adminActionLogs')
.where('targetPath', '==', `briefings/${FANDOM}_${day}`).get();
expect(logs.size).toBe(1);
expect(logs.docs[0]!.get('reason')).toBe('BRIEFING_PUBLISH');
expect(logs.docs[0]!.get('role')).toBe('master');
});
});
describe('당일 필터 — 앱이 읽는 질의', () => {
it('게시된 당일분만 고르고 초안과 전일분은 제외한다', async () => {
const db = getFirestore();
const today = dayKey(new Date());
const yesterday = dayKey(new Date(Date.now() - 24 * 60 * 60 * 1000));
await db.doc(`briefings/${FANDOM}_${today}_News_a`).set({
fandomId: FANDOM, dayKey: today, section: 'News', rank: 1,
status: 'published', headline: '오늘 게시분',
});
await db.doc(`briefings/${FANDOM}_${today}_News_b`).set({
fandomId: FANDOM, dayKey: today, section: 'News', rank: 2,
status: 'draft', headline: '오늘 초안',
});
await db.doc(`briefings/${FANDOM}_${yesterday}_News_a`).set({
fandomId: FANDOM, dayKey: yesterday, section: 'News', rank: 1,
status: 'published', headline: '어제 게시분',
});
const visible = await db.collection('briefings')
.where('fandomId', '==', FANDOM)
.where('status', '==', 'published')
.where('dayKey', '==', today)
.orderBy('rank')
.get();
expect(visible.docs.map((d) => d.get('headline'))).toEqual(['오늘 게시분']);
});
});
describe('푸시 토픽', () => {
it('구분마다 토픽을 나눈다 — OFF 구분은 구독 자체가 없다', () => {
expect(fandomTopic('bts', 'briefing')).toBe('fandom_bts_briefing');
expect(fandomTopic('bts', 'marketing')).toBe('fandom_bts_marketing');
});
it('FCM 이 받지 않는 문자를 걸러 낸다', () => {
expect(fandomTopic('b t/s', 'briefing')).toBe('fandom_b-t-s_briefing');
});
});
describe('이클립스 사전 알림 — 시작 − 3분 (원장 C-22)', () => {
/// 예약 회차 한 건을 심는다. [startInMin] 분 뒤 시작, 예고는 그 3분 전이다.
async function round(id: string, startInMin: number | null, extra: Record<string, unknown> = {}) {
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${id}`);
const start = Date.now() + (startInMin ?? 0) * 60_000;
await ref.set({
status: 'scheduled',
...(startInMin === null ? {} : {
startAt: Timestamp.fromMillis(start),
warnAt: Timestamp.fromMillis(start - 180_000),
}),
...extra,
});
return ref;
}
it('예고 시각이 지나고 시작 전이면 보내려 하고 판정 시각을 남긴다', async () => {
const ref = await round('fresh', 3);
const now = new Date();
// FCM 자격 증명이 없는 환경이라 발송 자체는 실패한다. 판정이 발송까지 갔다는 것이 확인 대상이다.
expect(await notifyEclipseWarning(await ref.get(), now)).toBe('failed');
expect((await ref.get()).get('alertPushedAt').toMillis()).toBe(now.getTime());
});
it('예고 시각 전에는 보내지 않고 표시도 남기지 않는다', async () => {
const ref = await round('early', 5);
expect(await notifyEclipseWarning(await ref.get())).toBe('too-early');
expect((await ref.get()).get('alertPushedAt')).toBeUndefined();
});
it('시작이 지난 회차에는 예고를 소급 발송하지 않는다', async () => {
const ref = await round('stale', -1);
expect(await notifyEclipseWarning(await ref.get())).toBe('too-old');
expect((await ref.get()).get('alertPushedAt')).toBeTruthy();
});
it('시각을 모르거나 취소된 회차에는 보내지 않는다', async () => {
const unknown = await round('unknown', null);
expect(await notifyEclipseWarning(await unknown.get())).toBe('no-start');
expect((await unknown.get()).get('alertPushedAt')).toBeUndefined();
const cancelled = await round('cancelled', 3, { status: 'cancelled' });
expect(await notifyEclipseWarning(await cancelled.get())).toBe('already');
});
it('같은 회차에 두 번 보내지 않는다 — 1 분 주기 점검이 도배하지 않는다', async () => {
const ref = await round('once', 3);
await notifyEclipseWarning(await ref.get());
expect(await notifyEclipseWarning(await ref.get())).toBe('already');
});
});
@@ -0,0 +1,494 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop, refundPop } from '../src/ledger/ledger.js';
import { fundCampaign } from '../src/fandom/fandom.js';
import { createCampaign, retryFeeRefunds, withdrawCampaign } from '../src/campaign/create.js';
import {
MAX_AUTO_REFUND_ATTEMPTS, cancelCampaign, fnCampaignAdminReview, fnCampaignReportSubmit, refundCampaign,
retryPendingRefunds, settleDueCampaigns, submitCampaignReport,
} from '../src/campaign/settle.js';
/**
* 캠페인 개설 · 정산 · 자동 환불 · 결과보고 — 별첨 1 ⑤ 판정 기준
* 「성공/실패 분기 정확 · 자동환불 무결성(부분 실패 방치·중복 환불 0건) · 결과보고 게시 확인」.
*
* 기간 단축은 `endsAt` 을 과거로 옮기고 정산 코어를 직접 부르는 것으로 한다 — 10분 스케줄을
* 기다리지 않고 같은 코드가 도는지를 본다.
*/
const CREATOR = 'test-cmp-creator';
/** 검증 활동(Quest 완료) 표식 n건 — Campaign 생성자격 5회(A안). */
async function completions(uid: string, n: number) {
for (let i = 0; i < n; i++) await getFirestore().doc(`users/${uid}/verifiedCompletions/q${i}_2026-09-0${i + 1}`).set({ questId: `q${i}` });
}
const A = 'test-cmp-donor-a';
const B = 'test-cmp-donor-b';
const db = () => getFirestore();
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
for (const uid of [CREATOR, A, B]) {
await db().recursiveDelete(db().collection(`users/${uid}/notifications`));
await wipeUser(uid);
}
// 정산·재시도는 컬렉션 전체를 훑으므로 다른 회차가 남긴 캠페인을 지운다.
await db().recursiveDelete(db().collection('campaigns'));
await db().recursiveDelete(db().collection('adminActionLogs'));
});
async function code(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const e = error as { details?: { code?: string }; code?: string };
return e.details?.code ?? e.code ?? String(error);
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 모금 중 캠페인과 후원들을 만들고 마감을 지난 시각으로 옮긴다(기간 단축). */
async function fundedCampaign(id: string, target: number, gifts: Array<[string, number]>) {
await db().doc(`campaigns/${id}`).set({
creatorUid: CREATOR, title: `캠페인 ${id}`, status: 'funding',
targetPop: target, raisedPop: 0, donorCount: 0, refundState: 'none',
endsAt: Timestamp.fromMillis(Date.now() + 60_000),
});
for (const [uid, amount] of gifts) {
await grantPop({
uid, popType: 'support', amount: 1_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${id}:${uid}`,
});
await fundCampaign({ uid, campaignId: id, clientSeq: '1', amount });
}
await db().doc(`campaigns/${id}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
}
const supportOf = async (uid: string) =>
(await db().doc(`users/${uid}/wallet/main`).get()).get('supportBalance') as number;
const refundRows = async (uid: string) => (await db()
.collection(`users/${uid}/popTransactions`)
.where('reasonCode', '==', 'CAMPAIGN_REFUND').get()).size;
const feeRefundRows = async (uid: string) => (await db()
.collection(`users/${uid}/popTransactions`)
.where('reasonCode', '==', 'CAMPAIGN_FEE_REFUND').get()).size;
const notes = async (uid: string) =>
(await db().collection(`users/${uid}/notifications`).get()).docs.map((d) => d.id);
describe('개설 — fn-campaign-create', () => {
beforeEach(async () => {
await db().doc(`users/${CREATOR}`).set({
displayName: '김실명', nickname: '개설자', fandomId: 'f-cmp', status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
});
await completions(CREATOR, 5);
});
const draft = { uid: CREATOR, title: '지하철 광고', body: '5주년', targetPop: 500, days: 7 };
it('등록비 100 POP(일반)을 빼고 review 로 올린다 · 같은 요청 재시도는 한 건', async () => {
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee' });
const first = await createCampaign({ ...draft, clientSeq: 's1' });
const again = await createCampaign({ ...draft, clientSeq: 's1' });
expect(again.campaignId).toBe(first.campaignId);
const doc = await db().doc(`campaigns/${first.campaignId}`).get();
expect(doc.get('status')).toBe('review');
expect(doc.get('fandomId')).toBe('f-cmp');
expect(doc.get('targetPop')).toBe(500);
// 개설자 이름은 공개 닉네임 — 공급자 실명(displayName)을 싣지 않는다(M-7).
expect(doc.get('creatorName')).toBe('개설자');
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(50);
const fees = await db().collection(`users/${CREATOR}/popTransactions`)
.where('reasonCode', '==', 'CAMPAIGN_FEE').get();
expect(fees.size).toBe(1);
// 진행 중 1건 보유 시 2건째 차단(B-56).
expect(await code(() => createCampaign({ ...draft, clientSeq: 's2' }))).toBe('E-PERM-01');
// 운영 승인으로 모금이 열린다.
await fnCampaignAdminReview.run({
data: { campaignId: first.campaignId, requestId: 'r1', approve: true, memo: 'ok' },
auth: { uid: 'ops-user', token: { role: 'ops' } },
} as never);
expect((await db().doc(`campaigns/${first.campaignId}`).get()).get('status')).toBe('funding');
});
it('검증 활동(Quest 완료) 5회 미만은 E-CMP-04 · 남은 횟수를 싣는다(Campaign 개설자격 A안)', async () => {
await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:elig' });
await db().recursiveDelete(db().collection(`users/${CREATOR}/verifiedCompletions`));
await completions(CREATOR, 3);
const err = await createCampaign({ ...draft, clientSeq: 'e1' }).then(() => null, (e: unknown) => e);
expect((err as { details?: Record<string, unknown> }).details).toEqual({ code: 'E-CMP-04', done: 3, need: 5 });
expect((await db().collection('campaigns').get()).size).toBe(0);
// 기준 횟수는 운영값이다.
await db().doc('config/campaign').set({ minVerifiedQuests: 3 }, { merge: true });
try {
expect((await createCampaign({ ...draft, clientSeq: 'e2' })).status).toBe('review');
} finally {
await db().doc('config/campaign').update({ minVerifiedQuests: FieldValue.delete() });
}
});
it('잔액 부족 · 가입 7일 미만은 거부하고 캠페인을 남기지 않는다', async () => {
expect(await code(() => createCampaign({ ...draft, clientSeq: 'poor' }))).toBe('E-POP-01');
await db().doc(`users/${CREATOR}`).update({ createdAt: Timestamp.now() });
expect(await code(() => createCampaign({ ...draft, clientSeq: 'new' }))).toBe('E-PERM-01');
expect((await db().collection('campaigns').get()).size).toBe(0);
});
});
describe('등록비 분할 · 반환 (B-57 · B-58)', () => {
beforeEach(async () => {
await db().doc(`users/${CREATOR}`).set({
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
});
await completions(CREATOR, 5);
await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee:a' });
await grantPop({ uid: CREATOR, popType: 'support', amount: 500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:fee:s' });
});
const draft = { uid: CREATOR, title: '광고', body: '본문', targetPop: 500, days: 7 };
const walletOf = async () => {
const w = await db().doc(`users/${CREATOR}/wallet/main`).get();
return [w.get('activityBalance'), w.get('supportBalance')];
};
const review = (campaignId: string, requestId: string, approve: boolean) => fnCampaignAdminReview.run({
data: { campaignId, requestId, approve, memo: 'm' },
auth: { uid: 'ops-user', token: { role: 'ops' } },
} as never);
it('일반 40 + 기여 60 으로 나눠 내고, 반려되면 각 타입으로 한 번만 돌려받는다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'split' });
expect(await walletOf()).toEqual([0, 440]);
const fee = await db().doc(`campaigns/${campaignId}`).get();
expect((fee.get('feeTxIds') as string[]).length).toBe(2);
await review(campaignId, 'r-reject', false);
await review(campaignId, 'r-reject', false);
const doc = await db().doc(`campaigns/${campaignId}`).get();
expect(doc.get('status')).toBe('rejected');
expect(doc.get('feeRefund')).toBe('done');
expect(await walletOf()).toEqual([40, 500]);
expect(await feeRefundRows(CREATOR)).toBe(2);
expect(await refundRows(CREATOR)).toBe(0);
});
it('반려 전이 뒤 반환 전에 끊겨도 — 새 요청 ID 의 재시도와 스케줄이 마저 돌려준다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'crash' });
// 트랜잭션은 섰고 반환 호출 전에 죽은 상태.
await db().doc(`campaigns/${campaignId}`).update({ status: 'rejected', feeRefund: 'due' });
expect(await code(() => review(campaignId, 'r-new', false))).toBe('E-CMP-03');
expect(await walletOf()).toEqual([40, 500]);
expect((await db().doc(`campaigns/${campaignId}`).get()).get('feeRefund')).toBe('done');
});
it('스케줄(retryFeeRefunds)이 남은 반환을 한 번만 돌린다 · 옛 feeTxId 한 건도', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'sweep' });
const ref = db().doc(`campaigns/${campaignId}`);
const [general] = (await ref.get()).get('feeTxIds') as string[];
// 분할 전 데이터 모양 — 단일 feeTxId(일반 몫만 남긴다).
await ref.update({ status: 'rejected', feeRefund: 'due', feeTxIds: FieldValue.delete(), feeTxId: general });
expect(await retryFeeRefunds()).toBe(1);
expect(await retryFeeRefunds()).toBe(0);
expect(await walletOf()).toEqual([40, 440]);
expect(await feeRefundRows(CREATOR)).toBe(1);
});
it('승인과 철회가 겹치면 한쪽 판정만 선다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'race' });
const [approved] = await Promise.allSettled([
review(campaignId, 'r-race', true),
withdrawCampaign(CREATOR, campaignId),
]);
const doc = await db().doc(`campaigns/${campaignId}`).get();
if (approved.status === 'fulfilled') {
// 승인이 먼저 — 철회는 공개 후 첫 후원 전 철회라 비환불.
expect(['funding', 'withdrawn']).toContain(doc.get('status'));
expect(await feeRefundRows(CREATOR)).toBe(0);
} else {
expect(doc.get('status')).toBe('withdrawn');
expect(await feeRefundRows(CREATOR)).toBe(2);
expect((await db().collection(`campaigns/${campaignId}/reviews`).get()).size).toBe(0);
}
});
it('공개 전 철회는 등록비를 돌려주고 재시도해도 한 번뿐이다', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'wd' });
expect((await withdrawCampaign(CREATOR, campaignId)).status).toBe('withdrawn');
await withdrawCampaign(CREATOR, campaignId);
expect(await walletOf()).toEqual([40, 500]);
expect(await feeRefundRows(CREATOR)).toBe(2);
// 철회한 캠페인은 진행 중 1건에 세지 않는다.
await createCampaign({ ...draft, clientSeq: 'wd2' });
});
it('공개 후 첫 후원 전 철회는 되지만 등록비는 비환불 · 첫 후원 뒤에는 E-PERM-01', async () => {
const { campaignId } = await createCampaign({ ...draft, clientSeq: 'pub' });
await review(campaignId, 'r-ok', true);
expect(await code(() => withdrawCampaign('someone-else', campaignId))).toBe('E-PERM-01');
await withdrawCampaign(CREATOR, campaignId);
expect((await db().doc(`campaigns/${campaignId}`).get()).get('status')).toBe('withdrawn');
expect(await walletOf()).toEqual([0, 440]);
expect(await feeRefundRows(CREATOR)).toBe(0);
await fundedCampaign('donated', 1_000, [[A, 10]]);
await db().doc('campaigns/donated').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) });
expect(await code(() => withdrawCampaign(CREATOR, 'donated'))).toBe('E-PERM-01');
});
});
describe('회사귀책 취소 — fn-campaign-admin-cancel (B-58)', () => {
it('등록비와 후원 전액을 한 번만 돌려주고 개설자 · 후원자에게 알린다', async () => {
await db().doc(`users/${CREATOR}`).set({
displayName: '개설자', fandomId: 'f-cmp', status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000),
});
await completions(CREATOR, 5);
await grantPop({ uid: CREATOR, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cancel' });
const { campaignId } = await createCampaign({
uid: CREATOR, title: '광고', body: '본문', targetPop: 1_000, days: 7, clientSeq: 'cancel',
});
await fnCampaignAdminReview.run({
data: { campaignId, requestId: 'ok', approve: true, memo: 'm' },
auth: { uid: 'ops-user', token: { role: 'ops' } },
} as never);
for (const [uid, amount] of [[A, 30], [B, 50]] as const) {
await grantPop({ uid, popType: 'support', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:cancel:${uid}` });
await fundCampaign({ uid, campaignId, clientSeq: '1', amount });
}
const first = await cancelCampaign(campaignId, 'ops-user', '운영 오류');
expect(first.refund.refunded).toBe(2);
await cancelCampaign(campaignId, 'ops-user', '운영 오류');
const doc = await db().doc(`campaigns/${campaignId}`).get();
expect(doc.get('status')).toBe('cancelled');
expect(doc.get('refundState')).toBe('done');
expect(doc.get('feeRefund')).toBe('done');
expect(await supportOf(A)).toBe(100);
expect(await supportOf(B)).toBe(100);
expect(await refundRows(A)).toBe(1);
expect(await refundRows(B)).toBe(1);
expect(await feeRefundRows(CREATOR)).toBe(1);
expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(100);
expect(await notes(CREATOR)).toEqual([`campaignCancel_${campaignId}`]);
expect((await notes(A)).length).toBe(1);
// 환불 알림 링크가 취소 문서를 열도록 규칙이 보는 후원자 표식(backers/{uid}).
const backers = await db().collection(`campaigns/${campaignId}/backers`).get();
expect(backers.docs.map((d) => d.id).sort()).toEqual([A, B].sort());
// 성공 · 실패로 끝난 캠페인은 취소 대상이 아니다.
await db().doc('campaigns/done').set({ creatorUid: CREATOR, status: 'success' });
expect(await code(() => cancelCampaign('done', 'ops-user', 'x'))).toBe('E-CMP-03');
});
});
describe('정산 분기', () => {
it('목표(targetPop) 달성은 success, 미달은 fail 이다', async () => {
await fundedCampaign('cmp-ok', 500, [[A, 300], [B, 200]]);
await fundedCampaign('cmp-short', 1_000, [[A, 100]]);
expect(await settleDueCampaigns()).toBe(2);
expect(await settleDueCampaigns()).toBe(0); // 두 번 판정하지 않는다.
const ok = await db().doc('campaigns/cmp-ok').get();
const short = await db().doc('campaigns/cmp-short').get();
expect(ok.get('status')).toBe('success');
expect(short.get('status')).toBe('fail');
expect(short.get('refundState')).toBe('pending');
expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000);
});
it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => {
await fundedCampaign('cmp-late', 1_000, [[A, 100]]);
expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 })))
.toBe('E-CMP-03');
expect((await db().doc('campaigns/cmp-late').get()).get('raisedPop')).toBe(100);
});
});
describe('자동 환불', () => {
it('실패 판정 뒤 관리자 호출 없이 전건을 돌려주고 알린다 · 재실행해도 중복 0', async () => {
await fundedCampaign('cmp-fail', 1_000, [[A, 300], [B, 200]]);
await settleDueCampaigns();
await retryPendingRefunds();
const campaign = await db().doc('campaigns/cmp-fail').get();
expect(campaign.get('refundState')).toBe('done');
expect(campaign.get('refundedCount')).toBe(2);
expect(campaign.get('lockedUntil')).toBeUndefined();
expect(await supportOf(A)).toBe(1_000);
expect(await supportOf(B)).toBe(1_000);
const donation = (await db().collection('campaigns/cmp-fail/donations').where('uid', '==', A).get()).docs[0]!;
expect(await notes(A)).toEqual([`campaignRefund_${donation.get('refundTxId')}`]);
expect(await retryPendingRefunds()).toBe(0);
await refundCampaign('cmp-fail', { manual: true });
expect(await refundRows(A)).toBe(1);
expect(await notes(A)).toHaveLength(1);
});
it('스케줄과 Admin 배치가 겹쳐도 후원 한 건당 한 번만 돌려준다', async () => {
await fundedCampaign('cmp-race', 1_000, [[A, 300], [B, 200]]);
await settleDueCampaigns();
const [auto, manual] = await Promise.all([
retryPendingRefunds(),
refundCampaign('cmp-race', { manual: true }),
]);
expect(auto + (manual.skipped ? 0 : 1)).toBeGreaterThanOrEqual(1);
expect(await refundRows(A)).toBe(1);
expect(await refundRows(B)).toBe(1);
expect(await supportOf(A)).toBe(1_000);
expect((await db().doc('campaigns/cmp-race').get()).get('refundState')).toBe('done');
});
it('잠금이 살아 있으면 건너뛴다', async () => {
await fundedCampaign('cmp-lock', 1_000, [[A, 300]]);
await settleDueCampaigns();
await db().doc('campaigns/cmp-lock').update({ lockedUntil: Timestamp.fromMillis(Date.now() + 60_000) });
expect((await refundCampaign('cmp-lock', { manual: true })).skipped).toBe('locked');
expect(await refundRows(A)).toBe(0);
});
it('다른 경로로 이미 돌려준 원거래는 환불 끝으로 보고, 알림이 빠졌으면 다시 보낸다', async () => {
await fundedCampaign('cmp-dup', 1_000, [[A, 300]]);
await settleDueCampaigns();
const donation = (await db().collection('campaigns/cmp-dup/donations').get()).docs[0]!;
await refundPop({
uid: A, originalTxId: donation.get('txId'),
idempotencyKey: 'manual-other-key', reasonCode: 'CAMPAIGN_REFUND',
});
await retryPendingRefunds();
expect((await db().doc('campaigns/cmp-dup').get()).get('refundState')).toBe('done');
expect(await refundRows(A)).toBe(1);
expect(await supportOf(A)).toBe(1_000);
expect(await notes(A)).toHaveLength(1);
// 환불 표시는 남고 알림 전에 죽은 경우 — 알림만 다시 간다.
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
await donation.ref.update({ refundNotifiedAt: null });
await db().doc('campaigns/cmp-dup').update({ refundState: 'partial' });
await retryPendingRefunds();
expect(await notes(A)).toHaveLength(1);
expect(await refundRows(A)).toBe(1);
});
it('부분 실패는 partial 로 남아 자동 재시도하고, 상한에서 Admin 큐로 간다', async () => {
await fundedCampaign('cmp-partial', 1_000, [[A, 300]]);
await db().doc('campaigns/cmp-partial/donations/broken').set({ uid: B, amount: 50 });
await settleDueCampaigns();
await retryPendingRefunds();
let campaign = await db().doc('campaigns/cmp-partial').get();
expect(campaign.get('refundState')).toBe('partial');
expect(campaign.get('refundFailedCount')).toBe(1);
expect(campaign.get('refundAttempts')).toBe(1);
expect(await supportOf(A)).toBe(1_000);
expect((await db().collection('adminActionLogs').get()).size).toBe(0);
for (let i = 1; i < MAX_AUTO_REFUND_ATTEMPTS; i++) await retryPendingRefunds();
campaign = await db().doc('campaigns/cmp-partial').get();
expect(campaign.get('refundAttempts')).toBe(MAX_AUTO_REFUND_ATTEMPTS);
expect(campaign.get('refundQueuedAt')).toBeTruthy();
const queue = await db().collection('adminActionLogs').get();
expect(queue.size).toBe(1);
expect(queue.docs[0]?.get('targetPath')).toBe('campaigns/cmp-partial');
expect(await retryPendingRefunds()).toBe(0);
expect(await refundRows(A)).toBe(1);
});
it('옛 정산의 failed 는 targetPop 으로 다시 판정한다 — 목표를 채웠으면 돌려주지 않는다', async () => {
// 예전 정산은 goalPop 만 읽어 목표를 채운 캠페인도 failed 로 적었다(refundAttempts 없음).
await fundedCampaign('cmp-legacy-met', 500, [[A, 500]]);
await fundedCampaign('cmp-legacy-short', 1_000, [[B, 200]]);
for (const id of ['cmp-legacy-met', 'cmp-legacy-short']) {
await db().doc(`campaigns/${id}`).update({ status: 'failed', refundState: 'pending' });
}
await retryPendingRefunds();
const met = await db().doc('campaigns/cmp-legacy-met').get();
expect(met.get('status')).toBe('failed');
expect(met.get('refundState')).toBe('pending');
expect(await refundRows(A)).toBe(0);
expect(await supportOf(A)).toBe(500);
expect((await refundCampaign('cmp-legacy-met', { manual: true })).skipped).toBe('legacy-goal-met');
const short = await db().doc('campaigns/cmp-legacy-short').get();
expect(short.get('status')).toBe('fail');
expect(short.get('refundState')).toBe('done');
expect(await supportOf(B)).toBe(1_000);
});
});
describe('결과보고 — 운영 전용 (B-35)', () => {
const file = (id: string, name: string) => `campaignReports/${id}/${name}`;
const uploaded = async () => true;
const report = (id: string) => ({
uid: 'ops-user', campaignId: id, body: '광고 집행 완료',
photoPaths: [file(id, 'p1.png')],
receiptPaths: [file(id, 'r1.png')],
budget: [{ label: '광고 집행', amount: 450 }, { label: '제작비', amount: 50 }],
});
it('성공 캠페인에 올리면 report 로 바뀌고 후원자에게 알린다 · 재호출은 중복이고 빠진 알림만 보낸다', async () => {
await fundedCampaign('cmp-rep', 500, [[A, 500]]);
await settleDueCampaigns();
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: false });
expect((await db().doc('campaigns/cmp-rep').get()).get('status')).toBe('report');
const doc = await db().doc('campaigns/cmp-rep/reports/main').get();
expect(doc.get('receiptPaths')).toEqual(['campaignReports/cmp-rep/r1.png']);
expect(doc.get('budgetTotal')).toBe(500);
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
// 게시 뒤 알림 전에 죽은 경우.
await db().recursiveDelete(db().collection(`users/${A}/notifications`));
await doc.ref.update({ notifiedAt: null });
expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: true });
expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']);
});
it('운영이 아니면 거부한다(개설자 포함)', async () => {
await fundedCampaign('cmp-rep-role', 500, [[A, 500]]);
await settleDueCampaigns();
expect(await code(() => fnCampaignReportSubmit.run({
data: { ...report('cmp-rep-role') }, auth: { uid: CREATOR, token: {} },
} as never))).toBe('E-PERM-01');
});
it('실패 캠페인 · 경로 우회 · URL · 올라가지 않은 파일 · 빈 영수증·예산은 거부한다', async () => {
await fundedCampaign('cmp-rep2', 500, [[A, 500]]);
await fundedCampaign('cmp-rep-fail', 1_000, [[B, 100]]);
await settleDueCampaigns();
const r = report('cmp-rep2');
expect(await code(() => submitCampaignReport(report('cmp-rep-fail'), uploaded))).toBe('E-CMP-03');
for (const bad of [
'campaignReports/cmp-rep2/../other/p.png',
'campaignReports/cmp-rep2/sub/p.png',
'campaignReports/other/p.png',
'users/ops-user/campaignReports/cmp-rep2/p.png',
'https://firebasestorage.googleapis.com/v0/b/x/o/campaignReports%2Fcmp-rep2%2Fp.png',
'campaignReports/cmp-rep2/..',
]) {
expect(await code(() => submitCampaignReport({ ...r, photoPaths: [bad] }, uploaded))).toBe('invalid-argument');
}
expect(await code(() => submitCampaignReport(r, async () => false))).toBe('invalid-argument');
expect(await code(() => submitCampaignReport({ ...r, receiptPaths: [] }, uploaded))).toBe('invalid-argument');
expect(await code(() => submitCampaignReport({ ...r, budget: [] }, uploaded))).toBe('invalid-argument');
expect(await code(() => submitCampaignReport(
{ ...r, budget: [{ label: '광고', amount: -1 }] }, uploaded,
))).toBe('invalid-argument');
expect((await db().doc('campaigns/cmp-rep2').get()).get('status')).toBe('success');
});
});
+488
View File
@@ -0,0 +1,488 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import { aggregateActiveEclipses, defendEclipse } from '../src/fandom/fandom.js';
/**
* 이클립스 방어 = **팬덤 공동 Shake 참여** · POP 소모 없음.
*
* 근거 — 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 · Firestore 설계서 v1.1
* §9-#5: *방어는 Shake 참여로 이루어지며 1 회당 POP 소모를 두지 않는다*. 별첨 1 ①
* 시나리오 4 의 「번개 연타·오토 도네이션」은 별첨 B 가 상위 기준이라 따르지 않는다
* (공통 기준 2항).
*
* 보는 것
* 1. **POP 이 움직이지 않는다** — 지갑과 원장이 그대로다.
* 2. **분산 카운터 유실 0** — 샤드를 흩어도 합이 참여 총량과 같다(별첨 1 ① 예외 처리).
* 3. **멱등** — 재시도가 샤드·방어 로그·티커 이벤트를 두 번 올리지 않는다(검수 D1·D6).
* 4. **서버 검증** — 단말이 보낸 흔들기 수를 상한과 속도로 다시 판정한다(별첨 1 ②).
* 5. **즉시 성공 판정** — 목표에 닿은 순간 `defended` 가 선다(검수 D2).
*/
const FANDOM = 'test-ecl-fandom';
const ECLIPSE = 'test-ecl-1';
const A = 'test-ecl-user-a';
const B = 'test-ecl-user-b';
/** 지갑 기준액. 방어가 POP 을 쓰지 않으므로 끝까지 이 값이어야 한다. */
const WALLET = 10_000;
beforeAll(() => { bootAdmin(); });
// 남긴 팬덤은 다음 파일의 팬덤 전수 질의에 섞인다. 끝나고 치운다.
afterAll(async () => {
await wipeUser(A);
await wipeUser(B);
await wipeEclipse();
await shutdownAdmin();
});
async function wipeEclipse(): Promise<void> {
const db = getFirestore();
const root = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
for (const sub of ['shards', 'defenseLogs', 'participants']) {
const snap = await root.collection(sub).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
const live = await db.collection(`fandoms/${FANDOM}/liveEvents`).get();
await Promise.all(live.docs.map((d) => d.ref.delete()));
await root.delete();
await db.doc(`fandoms/${FANDOM}`).delete();
await db.doc('config/eclipse').delete();
}
/**
* 활성 이클립스 하나와 사용자 둘을 세운다.
*
* 기본 설정은 **속도 제한을 끈다**(`minIntervalMs: 0`). 테스트는 같은 사용자로 연속
* 호출해 유실과 멱등을 보는데, 속도 제한이 켜져 있으면 그 확인 자체가 막힌다.
* 속도 제한은 전용 테스트에서 따로 켜서 본다.
*/
async function seed(overrides: Record<string, unknown> = {}): Promise<void> {
const db = getFirestore();
const target = (overrides.target as number | undefined) ?? 1_000_000;
await db.doc('config/eclipse').set({
enabled: true,
perCallMaxShakes: 50,
perUserMaxDefense: 500,
minIntervalMs: 0,
...overrides,
});
await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS', fandomName: 'BTS' });
await db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).set({
status: 'active',
target,
erosionRate: 100,
defense: 0,
defenseCount: 0,
defended: false,
startAt: FieldValue.serverTimestamp(),
endsAt: Timestamp.fromMillis(Date.now() + 30 * 60 * 1000),
});
const people: [string, string][] = [[A, '아미보이'], [B, '버니즈']];
for (const [uid, nickname] of people) {
await db.doc(`users/${uid}`).set({ uid, nickname, fandomId: FANDOM });
await grantPop({
uid, popType: 'activity', amount: WALLET,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:ecl:${uid}`,
});
}
}
/** 샤드 합. 「분산 카운터로 카운트 유실 없음」의 확인 수단이다. */
async function shardSum(): Promise<{ defense: number; calls: number }> {
const snap = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/shards`).get();
return {
defense: snap.docs.reduce((a, d) => a + ((d.get('defense') as number) ?? 0), 0),
calls: snap.docs.reduce((a, d) => a + ((d.get('defenseCount') as number) ?? 0), 0),
};
}
const countOf = async (path: string): Promise<number> =>
(await getFirestore().collection(path).get()).size;
const logCount = () =>
countOf(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/defenseLogs`);
const tickerCount = () => countOf(`fandoms/${FANDOM}/liveEvents`);
async function balanceOf(uid: string): Promise<number> {
const snap = await getFirestore().doc(`users/${uid}/wallet/main`).get();
return (snap.get('activityBalance') as number | undefined) ?? 0;
}
beforeEach(async () => {
await wipeUser(A);
await wipeUser(B);
await wipeEclipse();
});
describe('방어는 POP 을 소모하지 않는다 (설계서 v1.1 §9-#5)', () => {
it('참여해도 지갑과 원장이 그대로다', async () => {
await seed();
const db = getFirestore();
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20,
});
// 지갑이 한 푼도 움직이지 않는다.
expect(await balanceOf(A)).toBe(WALLET);
// 방어로 원장 행이 생기지 않는다 — 지급 1 건(ADMIN_GRANT)뿐이다.
const ledger = await db.collection(`users/${A}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(ledger.docs[0]!.get('reasonCode')).toBe('ADMIN_GRANT');
// 누적 기여도 움직이지 않는다 — 방어는 기여 POP 이 아니다.
const user = await db.doc(`users/${A}`).get();
expect(user.get('totalContributedPop') ?? 0).toBe(0);
});
it('참여량은 흔들기 수로 쌓인다', async () => {
await seed();
const result = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 20,
});
expect(result.accepted).toBe(20);
expect(result.clamped).toBe(0);
expect(result.myTotal).toBe(20);
expect(await shardSum()).toEqual({ defense: 20, calls: 1 });
});
});
describe('분산 카운터 — 카운트 유실 0 (별첨 1 ① 예외 처리)', () => {
it('샤드를 흩어 적어도 합이 참여 총량과 같다', async () => {
await seed();
const shakes = [1, 2, 3, 1, 5, 1, 2, 1, 3, 1];
for (const [i, n] of shakes.entries()) {
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `seq-${i}`, shakes: n,
});
}
const total = shakes.reduce((a, b) => a + b, 0);
expect(await shardSum()).toEqual({ defense: total, calls: shakes.length });
await aggregateActiveEclipses();
const summary = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
expect(summary.get('defense')).toBe(total);
expect(summary.get('defenseCount')).toBe(shakes.length);
expect(summary.get('erosionRate')).toBe(
100,
);
});
it('두 사용자가 동시에 참여해도 집계가 일치한다', async () => {
await seed();
await Promise.all([
...Array.from({ length: 10 }, (_, i) => defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `a-${i}`, shakes: 3,
})),
...Array.from({ length: 10 }, (_, i) => defendEclipse({
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: `b-${i}`, shakes: 3,
})),
]);
await aggregateActiveEclipses();
const summary = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
expect(summary.get('defense')).toBe(60);
expect(summary.get('defenseCount')).toBe(20);
expect(await logCount()).toBe(20);
expect(await tickerCount()).toBe(20);
});
});
describe('멱등 — 재시도가 이중 계산되지 않는다 (검수 D1·D6)', () => {
it('같은 clientSeq 를 두 번 보내도 샤드·로그·티커가 한 번만 늘어난다', async () => {
await seed();
const first = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30,
});
const again = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'same', shakes: 30,
});
expect(first.duplicate).toBe(false);
expect(again.duplicate).toBe(true);
expect(again.accepted).toBe(first.accepted);
// 여기가 검수 지적의 자리다 — 예전에는 60/2/2 가 되었다.
expect(await shardSum()).toEqual({ defense: 30, calls: 1 });
expect(await logCount()).toBe(1);
expect(await tickerCount()).toBe(1);
// 참여자 누적도 한 번만 오른다.
const me = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}/participants/${A}`).get();
expect(me.get('validShakes')).toBe(30);
expect(me.get('calls')).toBe(1);
});
it('같은 키를 동시에 다섯 번 보내도 한 번만 쌓인다', async () => {
await seed();
// 경합이 문서 ID 충돌로 판정되는지 본다. 조회 후 쓰기라면 여기서 여러 번 쌓인다.
const results = await Promise.allSettled(
Array.from({ length: 5 }, () => defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'race', shakes: 10,
})),
);
expect(results.some((r) => r.status === 'fulfilled')).toBe(true);
expect(await shardSum()).toEqual({ defense: 10, calls: 1 });
expect(await logCount()).toBe(1);
expect(await tickerCount()).toBe(1);
});
});
describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () => {
it('한 호출 상한을 넘긴 흔들기 수를 깎는다', async () => {
await seed({ perCallMaxShakes: 10 });
const result = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 9_999,
});
expect(result.accepted).toBe(10);
expect(result.clamped).toBe(9_989);
expect((await shardSum()).defense).toBe(10);
});
it('사용자당 상한에 닿으면 더 쌓이지 않는다', async () => {
await seed({ perCallMaxShakes: 50, perUserMaxDefense: 60 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 50,
});
const second = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 50,
});
// 남은 10 만 받는다 — 통째로 버리면 정상 참여의 마지막 묶음이 사라진다.
expect(second.accepted).toBe(10);
expect(second.clamped).toBe(40);
const third = await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's3', shakes: 10,
});
expect(third.accepted).toBe(0);
expect((await shardSum()).defense).toBe(60);
// 받아들인 것이 없으면 티커에도 올리지 않는다 — 빈 참여가 흐르면 안 된다.
expect(await tickerCount()).toBe(2);
});
it('속도 제한보다 빠른 연속 호출을 끊는다', async () => {
await seed({ minIntervalMs: 60_000 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's1', shakes: 5,
});
await expect(defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 's2', shakes: 5,
})).rejects.toMatchObject({ details: { code: 'E-QST-02' } });
expect((await shardSum()).defense).toBe(5);
});
it('다른 사용자는 서로의 속도 제한에 걸리지 않는다', async () => {
await seed({ minIntervalMs: 60_000 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'a1', shakes: 5,
});
await defendEclipse({
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'b1', shakes: 5,
});
expect((await shardSum()).defense).toBe(10);
});
});
describe('티커 이벤트 — Global Ticker 모양 (별첨 B)', () => {
it('참여마다 종류·닉네임·참여량·우선순위·이동처를 남긴다', async () => {
await seed();
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't1', shakes: 7,
});
const events = await getFirestore()
.collection(`fandoms/${FANDOM}/liveEvents`).get();
expect(events.size).toBe(1);
const event = events.docs[0]!;
// 다른 종류(Artist Hot Time · Hero Gift · Campaign · 공지)가 뒤에 들어올 자리다.
expect(event.get('kind')).toBe('eclipse_defense');
expect(event.get('nickname')).toBe('아미보이');
expect(event.get('defense')).toBe(7);
expect(event.get('eclipseId')).toBe(ECLIPSE);
expect(typeof event.get('priority')).toBe('number');
expect(event.get('linkTo')).toBe(`/eclipse/${FANDOM}/${ECLIPSE}`);
// 티커가 최신순으로 읽으므로 시각이 없으면 정렬 자체가 불가능하다.
expect(event.get('createdAt')).toBeTruthy();
// TTL 정책의 입력. 없으면 컬렉션이 무한히 쌓인다.
expect((event.get('expiresAt') as Timestamp).toMillis())
.toBeGreaterThan(Date.now());
});
it('닉네임이 없는 사용자도 문구가 비지 않는다', async () => {
await seed();
await getFirestore().doc(`users/${A}`).set({ nickname: '' }, { merge: true });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 't2', shakes: 1,
});
const events = await getFirestore()
.collection(`fandoms/${FANDOM}/liveEvents`).get();
expect(events.docs[0]!.get('nickname')).toBe('팬');
});
});
describe('성공 판정 — 목표에 닿은 즉시 (검수 D2)', () => {
it('한 사람의 추가 Shake는 목표 두 명을 채울 수 없고 두 번째 60회에서만 성공한다', async () => {
await seed({ target: 2, perCallMaxShakes: 60 });
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({
uid, clientSeq, shakes, fandomId: FANDOM, eclipseId: ECLIPSE,
});
await send(A, 'one', 60);
await send(A, 'extra', 60);
await send(B, 'under', 59);
await aggregateActiveEclipses();
expect((await ref.get()).data()).toMatchObject({
qualifiedParticipants: 1, defended: false, erosionRate: 50,
});
await send(B, 'two', 1);
const result = await ref.get();
expect(result.get('defended')).toBe(true);
expect(result.get('qualifiedParticipants')).toBe(2);
const at = result.get('defendedAt');
await aggregateActiveEclipses();
expect((await ref.get()).get('defendedAt')).toEqual(at);
});
it('인원 목표를 판정할 수 없으면 옛 Shake 목표로 성공시키지 않고 취소한다', async () => {
await seed();
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
await ref.update({ target: FieldValue.delete(), targetPop: 1 });
await aggregateActiveEclipses();
expect((await ref.get()).data()).toMatchObject({
status: 'cancelled', result: 'cancelled_error', defended: false,
});
});
it('집계를 기다리지 않고 방어 호출이 defended 를 세운다', async () => {
await seed({ target: 1, perCallMaxShakes: 60 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60,
});
// 집계(aggregateActiveEclipses)를 한 번도 돌리지 않았다.
const snap = await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`).get();
expect(snap.get('defended')).toBe(true);
expect(snap.get('erosionRate')).toBe(0);
// 활성 질의에서 빠지면 화면이 성공한 순간을 놓친다 — status 는 그대로다.
expect(snap.get('status')).toBe('active');
});
it('성공한 이클립스는 더 받지 않는다 (E-ECL-01)', async () => {
await seed({ target: 1, perCallMaxShakes: 60 });
await defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w1', shakes: 60,
});
await expect(defendEclipse({
uid: B, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'w2', shakes: 5,
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
expect((await shardSum()).defense).toBe(60);
});
it('종료된 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => {
await seed();
await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`)
.set({ status: 'ended' }, { merge: true });
await expect(defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c1', shakes: 1,
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
});
it('마감이 지난 이클립스는 참여를 받지 않는다 (E-ECL-01)', async () => {
await seed();
await getFirestore()
.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`)
.set({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) }, { merge: true });
await expect(defendEclipse({
uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'c2', shakes: 1,
})).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
});
});
describe('v1.5 유효 참여의 원자적 기록', () => {
it('59회는 미인정, 60회는 한 명이며 추가 참여·동시 요청·재전송도 중복 인정하지 않는다', async () => {
await seed();
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
const send = (uid: string, clientSeq: string, shakes: number) => defendEclipse({
uid, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq, shakes,
});
const qualifiedCount = async () => (await ref.collection('shards').get()).docs
.reduce((sum, doc) => sum + (doc.get('qualifiedParticipants') ?? 0), 0);
await send(A, 'q50', 50);
await send(A, 'q59', 9);
expect(await qualifiedCount()).toBe(0);
expect((await ref.collection('participants').doc(A).get()).get('qualified')).toBeUndefined();
await Promise.all([send(A, 'q60', 1), send(A, 'q60', 1)]);
const qualifiedAt = (await ref.collection('participants').doc(A).get()).get('qualifiedAt');
expect(qualifiedAt).toBeInstanceOf(Timestamp);
expect(await qualifiedCount()).toBe(1);
// 120회를 흔들어도 1명이다(C-25).
await Promise.all([send(A, 'extra1', 20), send(A, 'extra2', 20), send(A, 'extra3', 20)]);
await send(B, 'b50', 50);
await send(B, 'b60', 10);
expect(await qualifiedCount()).toBe(2);
expect((await ref.collection('participants').doc(A).get()).get('qualifiedAt')).toEqual(qualifiedAt);
expect((await ref.collection('participants').doc(A).get()).get('validShakes')).toBe(120);
expect((await send(A, 'q60', 1)).myTotal).toBe(60);
await aggregateActiveEclipses();
expect((await ref.get()).get('qualifiedParticipants')).toBe(2);
});
it('마지막 티커 저장이 실패하면 로그·개인 누적·샤드까지 취소하고 같은 요청으로 복구한다', async () => {
await seed();
const db = getFirestore();
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'atomic', shakes: 30 };
const original = Transaction.prototype.create;
let calls = 0;
const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementation(function (this: Transaction, ...args) {
if (++calls === 2) throw new Error('ticker write failed');
return original.apply(this, args);
});
try {
await expect(defendEclipse(input)).rejects.toThrow('ticker write failed');
} finally { spy.mockRestore(); }
for (const sub of ['defenseLogs', 'participants', 'shards']) {
expect((await ref.collection(sub).get()).size).toBe(0);
}
expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(0);
expect((await defendEclipse(input)).duplicate).toBe(false);
expect(await shardSum()).toEqual({ defense: 30, calls: 1 });
expect((await db.collection(`fandoms/${FANDOM}/liveEvents`).get()).size).toBe(1);
});
it('회차가 닫혀도 확정된 요청은 재확인할 수 있고 신규 요청은 거절한다', async () => {
await seed();
const input = { uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'lost-response', shakes: 10 };
await defendEclipse(input);
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
await ref.update({ status: 'ended', endsAt: Timestamp.fromMillis(0) });
expect(await defendEclipse(input)).toEqual({ accepted: 10, clamped: 0, myTotal: 10, duplicate: true });
await expect(defendEclipse({ ...input, clientSeq: 'late' })).rejects.toMatchObject({ details: { code: 'E-ECL-01' } });
expect(await shardSum()).toEqual({ defense: 10, calls: 1 });
});
});
describe('개인 인정 횟수는 운영값이다 (원장 C-25)', () => {
it('회차에 고정된 인정 횟수로 판정하고 운영값 문서만 바꿔서는 진행 중 회차 기준이 바뀌지 않는다', async () => {
await seed({ target: 1, perCallMaxShakes: 60, qualifyShakes: 60 });
const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${ECLIPSE}`);
await ref.update({ qualifyShakes: 30 });
await getFirestore().doc('config/eclipse').set({ qualifyShakes: 90 }, { merge: true });
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q29', shakes: 29 });
expect((await ref.get()).get('defended')).toBe(false);
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'q30', shakes: 1 });
expect((await ref.get()).get('defended')).toBe(true);
});
});
@@ -0,0 +1,48 @@
import { afterAll, beforeAll, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { eclipseTarget } from '../src/fandom/eclipse_target.js';
import { bootAdmin, shutdownAdmin } from './support.js';
beforeAll(bootAdmin);
afterAll(async () => {
await getFirestore().recursiveDelete(getFirestore().doc('fandoms/test-eclipse-target'));
await shutdownAdmin();
});
it('생성일·0명·누락·최근 7일·목표율을 구분해 고유 참여자 목표를 산출한다', async () => {
const ref = getFirestore().doc('fandoms/test-eclipse-target');
const created = (date: string) => Timestamp.fromDate(new Date(`${date}T00:00:00+09:00`));
const stats = ref.collection('fandomDailyStats');
expect(await eclipseTarget(ref, created('2026-09-22'), '2026-09-22')).toEqual({
target: 1, averageDau: null, dauDays: [],
});
expect(await eclipseTarget(ref, undefined, '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
expect(await eclipseTarget(ref, created('2026-09-23'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
expect(await eclipseTarget(ref, created('2026-09-21'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
await stats.doc('2026-09-21').set({ dau: 0, active30: 10000 });
expect(await eclipseTarget(ref, created('2026-09-21'), '2026-09-22')).toMatchObject({ target: 1, averageDau: 0 });
await stats.doc('2026-09-20').set({ dau: 30 });
await stats.doc('2026-09-19').set({ dau: 60 });
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22')).toEqual({
target: 2, averageDau: 30, dauDays: ['2026-09-21', '2026-09-20', '2026-09-19'],
});
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22', 0.1)).toMatchObject({ target: 3 });
for (const rate of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 1.1]) {
expect(await eclipseTarget(ref, created('2026-09-19'), '2026-09-22', rate)).toMatchObject({ result: 'cancelled_error' });
}
for (let d = 15; d <= 21; d++) await stats.doc(`2026-09-${d}`).set({ dau: 30 });
await stats.doc('2026-09-14').set({ dau: 100000 });
await stats.doc('2026-09-22').set({ dau: 100000 });
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ target: 2, averageDau: 30 });
// C-21 집계 기간은 운영값 — 8일로 늘리면 14일의 값이 분모에 들어온다.
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22', 0.05, 8)).toMatchObject({
target: Math.ceil((30 * 7 + 100000) / 8 * 0.05), averageDau: (30 * 7 + 100000) / 8,
});
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22', 0.05, 0)).toMatchObject({ result: 'cancelled_error' });
for (const invalid of [-1, 0.5, '30', null]) {
await stats.doc('2026-09-20').set({ dau: invalid });
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
}
await stats.doc('2026-09-20').delete();
expect(await eclipseTarget(ref, created('2026-09-01'), '2026-09-22')).toMatchObject({ result: 'cancelled_error' });
});
@@ -0,0 +1,476 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import {
aggregateActiveEclipses,
defendEclipse,
runEclipseSchedule,
fnFpiAggregate,
} from '../src/fandom/fandom.js';
/** v1.5 §5: 운영 일정, DAU 오류 취소, 시작 스냅샷, 실패 확정 차감과 경합을 검증한다. */
const FANDOM = 'test-trig-fandom';
const A = 'test-trig-user-a';
/** 침식률 — 수치표 v1.4 「침식 0.25% FPI」. */
const EROSION_PERCENT = 0.25;
/**
* 방어 목표(팬덤 공동 Shake 누적). **POP 이 아니다** — 방어는 POP 을 소모하지 않는다
* (설계서 v1.1 §9-#5). 한 번의 참여로 채울 수 있게 작게 잡았다.
*/
const TARGET = 60;
/** 축별 기준 FPI. 0.25% 가 정수로 떨어지도록 400의 배수로 잡았다. */
const BASE_AXES: Record<string, number> = {
bpm: 400_000,
health: 200_000,
referral: 80_000,
community: 40_000,
contribution: 120_000,
};
const AXES = Object.keys(BASE_AXES);
/** 기준 FPI 총합. 침식 전후를 이 값으로 견준다. */
const BASE_TOTAL = Object.values(BASE_AXES).reduce((a, b) => a + b, 0);
/** KST 시각을 절대 시각으로. 발동 시각 판정이 KST 기준이다. */
const kst = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+09:00`);
const DAY = '2026-09-20';
const FIRST = '13:00';
const SECOND = '21:00';
const WARN_SECONDS = 300;
beforeAll(() => {
// 발동과 방어가 같은 시각을 보도록 Date만 고정한다. Firestore 타이머는 실제로 돈다.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(kst(DAY, '13:10'));
bootAdmin();
});
// **끝나고도 치운다.** 남겨 두면 다음 파일의 전역 질의(`fn-fpi-aggregate` ·
// `fn-eclipse-trigger` 는 팬덤 전수를 돈다)가 이 팬덤까지 집어 값이 흔들린다.
afterAll(async () => {
await wipeUser(A);
await wipeFandom();
await shutdownAdmin();
vi.useRealTimers();
});
async function wipeFandom(): Promise<void> {
const db = getFirestore();
const root = db.doc(`fandoms/${FANDOM}`);
const eclipses = await root.collection('eclipseRounds').get();
for (const doc of eclipses.docs) {
for (const sub of ['shards', 'defenseLogs', 'participants']) {
const snap = await doc.ref.collection(sub).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
await doc.ref.delete();
}
for (const sub of ['fpiEvents', 'liveEvents', 'members', 'fandomDailyStats']) {
const snap = await root.collection(sub).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
await root.delete();
await db.doc('config/eclipse').delete();
}
/**
* 팬덤 하나에 FPI 원장을 세운다.
*
* **`fpiAxes` 를 직접 쓰지 않고 이벤트로 만든다.** 집계가 이벤트에서 요약을 다시
* 만들므로, 요약만 심어 두면 첫 집계에서 전부 0 이 되어 침식할 것이 없어진다.
*/
async function seedFandom(overrides: Record<string, unknown> = {}): Promise<void> {
const db = getFirestore();
await db.doc('config/eclipse').set({
enabled: true,
schedule: [{ hhmm: FIRST }, { hhmm: SECOND }],
erosionPercent: EROSION_PERCENT,
warnSeconds: WARN_SECONDS,
perCallMaxShakes: 60,
windowSec: 1800,
// 같은 사용자로 연속 참여해 보므로 속도 제한을 끈다. 제한 자체는
// eclipse.test.ts 의 전용 테스트가 본다.
minIntervalMs: 0,
...overrides,
});
await db.doc(`fandoms/${FANDOM}`).set({
name: 'BTS', stage: 1, fandomName: 'BTS',
createdAt: Timestamp.fromDate(kst(DAY, '00:00')),
});
// 활동 이벤트는 발동 시각보다 앞서고 7 일 창 안이다.
const occurredAt = Timestamp.fromDate(kst(DAY, '09:00'));
for (const axis of AXES) {
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-${axis}`).set({
axis, value: BASE_AXES[axis], verified: true, occurredAt,
reasonCode: 'SEED',
});
}
await db.doc(`users/${A}`).set({ uid: A, nickname: '아미보이', fandomId: FANDOM });
await grantPop({
uid: A, popType: 'activity', amount: 10_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:trig:${A}`,
});
}
/** 집계를 실제로 한 번 돌린다. 스케줄 래퍼를 직접 호출한다. */
const aggregateFpi = () =>
(fnFpiAggregate.run as (e: unknown) => Promise<void>)({});
/** 가중치와 실패 차감까지 반영한 현재 FPI 총점. */
async function currentFpiTotal(): Promise<number> {
const snap = await getFirestore().doc(`fandoms/${FANDOM}`).get();
return snap.get('fpiScore') as number;
}
/** 사유 코드별 FPI 이벤트 수. 「감소 경로가 Eclipse 뿐인가」의 확인 수단이다. */
async function fpiEventsByReason(): Promise<Record<string, number>> {
const snap = await getFirestore()
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
const out: Record<string, number> = {};
for (const doc of snap.docs) {
const reason = (doc.get('reasonCode') as string | undefined) ?? '(없음)';
out[reason] = (out[reason] ?? 0) + 1;
}
return out;
}
beforeEach(async () => {
await wipeUser(A);
await wipeFandom();
});
describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
it('DAU 누락 회차는 취소 사유를 남기고 활성화·FPI 차감 없이 종료한다', async () => {
await seedFandom();
await aggregateFpi();
const db = getFirestore();
await db.doc(`fandoms/${FANDOM}`).update({ createdAt: Timestamp.fromDate(kst('2026-09-19', '00:00')) });
await runEclipseSchedule(kst(DAY, FIRST));
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
expect((await ref.get()).data()).toMatchObject({
status: 'cancelled', result: 'cancelled_error', cancelReason: 'MISSING_OR_INVALID_DAU',
});
expect((await ref.get()).get('activatedAt')).toBeUndefined();
expect((await fpiEventsByReason())['ECLIPSE_EROSION'] ?? 0).toBe(0);
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
await runEclipseSchedule(kst(DAY, FIRST));
expect((await ref.get()).get('status')).toBe('cancelled');
await db.doc(`fandoms/${FANDOM}/fandomDailyStats/2026-09-19`).set({ dau: 30 });
await runEclipseSchedule(kst(DAY, FIRST));
expect((await ref.get()).get('status')).toBe('cancelled');
await runEclipseSchedule(kst(DAY, SECOND));
expect((await db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-2`).get()).data()).toMatchObject({
status: 'active', target: 2, averageDau: 30,
});
});
it('v1.5 기본값으로 07:47/17:47 예고하고 07:50/17:50부터 60초 방어한다', async () => {
const db = getFirestore();
await db.doc('config/eclipse').set({ enabled: true });
await db.doc(`fandoms/${FANDOM}`).set({ name: '기본 운영값 검증', fpiScore: 0, createdAt: Timestamp.fromDate(kst(DAY, '00:00')) });
for (const [round, warning, start] of [[1, '07:47', '07:50'], [2, '17:47', '17:50']] as const) {
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-${round}`);
await runEclipseSchedule(new Date(kst(DAY, warning).getTime() - 1));
expect((await ref.get()).exists).toBe(false);
await runEclipseSchedule(kst(DAY, warning));
const scheduled = await ref.get();
expect(scheduled.get('status')).toBe('scheduled');
expect(scheduled.get('target')).toBe(1);
expect(scheduled.get('dauDays')).toEqual([]);
expect(scheduled.get('warnAt').toMillis()).toBe(kst(DAY, warning).getTime());
expect(scheduled.get('startAt').toMillis()).toBe(kst(DAY, start).getTime());
expect(scheduled.get('endsAt').toMillis() - scheduled.get('startAt').toMillis()).toBe(60_000);
expect(scheduled.get('windowSec')).toBe(60);
expect(scheduled.get('qualifyShakes')).toBe(60);
// C-22 「푸시 발송 시각 = 시작 − 3분」 — 예고 판정 시각이 회차 문서에 남는다.
expect(scheduled.get('alertPushedAt').toMillis()).toBe(kst(DAY, warning).getTime());
await runEclipseSchedule(kst(DAY, start));
expect((await ref.get()).get('status')).toBe('active');
await runEclipseSchedule(new Date(kst(DAY, start).getTime() + 60_000));
expect((await ref.get()).get('status')).toBe('ended');
}
});
it('사전 알림 시각에 예약되고 발동 시각에 활성이 된다', async () => {
await seedFandom();
await aggregateFpi();
const db = getFirestore();
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
// **반환 카운터를 단정하지 않는다.** `runEclipseSchedule` 은 모든 팬덤을 돌아
// 전역 건수를 돌려주므로, 다른 테스트가 남긴 팬덤이 있으면 값이 흔들린다.
// 이 테스트가 볼 것은 「이 팬덤의 이 회차 문서가 어떤 상태인가」다.
// 사전 알림 전에는 문서가 없다.
await runEclipseSchedule(kst(DAY, '12:50'));
expect((await ref.get()).exists).toBe(false);
// 사전 알림 시각(발동 300초 전 = 12:55)에 예약된다.
await runEclipseSchedule(kst(DAY, '12:56'));
let snap = await ref.get();
expect(snap.get('status')).toBe('scheduled');
// 푸시 워커가 읽을 사전 알림 시각. 예약 시점보다 앞이면 예고가 불가능하다.
expect((snap.get('warnAt') as Timestamp).toMillis())
.toBe(kst(DAY, '12:55').getTime());
expect((snap.get('startAt') as Timestamp).toMillis())
.toBe(kst(DAY, FIRST).getTime());
// 발동 시각에 활성이 된다.
await runEclipseSchedule(kst(DAY, '13:00'));
snap = await ref.get();
expect(snap.get('status')).toBe('active');
});
it('하루에 1차·2차 딱 둘만 만든다', async () => {
await seedFandom();
await aggregateFpi();
// 하루를 1분 간격이 아니라 구간별로 훑는다.
for (const at of ['00:10', '12:56', '13:00', '13:20', '20:56', '21:00', '23:50']) {
await runEclipseSchedule(kst(DAY, at));
}
const eclipses = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
expect(eclipses.size).toBe(2);
expect(eclipses.docs.map((d) => d.id).sort()).toEqual([`${DAY}-1`, `${DAY}-2`]);
expect(eclipses.docs.map((d) => d.get('round')).sort()).toEqual([1, 2]);
});
it('같은 회차를 여러 번 돌려도 중복 발동 0 건이다', async () => {
await seedFandom();
await aggregateFpi();
// 같은 분을 열 번 돌린다. 조회 후 쓰기로 막았다면 여기서 새 문서가 생긴다.
for (let i = 0; i < 10; i++) await runEclipseSchedule(kst(DAY, '13:05'));
// 동시에도 돌려 본다 — 스케줄 인스턴스가 겹치는 상황이다.
await Promise.all(
Array.from({ length: 5 }, () => runEclipseSchedule(kst(DAY, '13:06'))),
);
const eclipses = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
expect(eclipses.size).toBe(1);
expect(eclipses.docs[0]!.get('status')).toBe('active');
// 시작 시점에는 차감 이벤트가 없어야 한다.
const events = await getFirestore()
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
const erosions = events.docs
.filter((d) => d.get('reasonCode') === 'ECLIPSE_EROSION');
expect(erosions).toHaveLength(0);
});
it('활성화가 꺼진 팬덤은 발동하지 않는다', async () => {
await seedFandom({ enabled: false });
await aggregateFpi();
await runEclipseSchedule(kst(DAY, '13:05'));
const eclipses = await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
expect(eclipses.size).toBe(0);
});
it('발동 시각·침식률을 운영값으로 바꾸면 재배포 없이 따라간다', async () => {
// 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
await seedFandom({ schedule: [{ hhmm: '06:30' }], erosionPercent: 1, warnSeconds: 60 });
await aggregateFpi();
await runEclipseSchedule(kst(DAY, '06:20'));
expect((await getFirestore()
.collection(`fandoms/${FANDOM}/eclipseRounds`).get()).size).toBe(0);
await runEclipseSchedule(kst(DAY, '06:31'));
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
expect(snap.get('status')).toBe('active');
expect(snap.get('erosionPercent')).toBe(1);
expect(snap.get('fpiAtStart')).toBe(BASE_TOTAL);
expect(snap.get('erodedTotal')).toBeUndefined();
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
});
it('v0.3.0 팬덤별 firstAt 설정도 하위호환으로 읽는다', async () => {
await seedFandom();
await aggregateFpi();
await getFirestore().doc(`fandoms/${FANDOM}`)
.set({ eclipseSettings: { firstAt: '08:00' } }, { merge: true });
await runEclipseSchedule(kst(DAY, '08:01'));
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
expect((snap.get('startAt') as Timestamp).toMillis())
.toBe(kst(DAY, '08:00').getTime());
});
it('방어 창이 지나면 닫는다', async () => {
await seedFandom();
await aggregateFpi();
await runEclipseSchedule(kst(DAY, '13:01'));
// 창은 30분이다.
await runEclipseSchedule(kst(DAY, '13:20'));
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
.get('status')).toBe('active');
await runEclipseSchedule(kst(DAY, '13:31'));
// 검수 D3 — 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
.get('status')).toBe('ended');
});
});
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (원장 C-24)', () => {
const ids = async () => (await getFirestore().collection(`fandoms/${FANDOM}/eclipseRounds`).get())
.docs.map((d) => [d.id, (d.get('startAt') as Timestamp).toMillis()]).sort();
it('공통 일정 3회를 설정하면 하루 3회차를 만든다', async () => {
await seedFandom({ schedule: [{ hhmm: '07:50' }, { hhmm: '12:00' }, { hhmm: '17:50' }] });
await aggregateFpi();
for (const at of ['07:50', '12:00', '17:50', '23:59']) await runEclipseSchedule(kst(DAY, at));
expect(await ids()).toEqual([
[`${DAY}-1`, kst(DAY, '07:50').getTime()],
[`${DAY}-2`, kst(DAY, '12:00').getTime()],
[`${DAY}-3`, kst(DAY, '17:50').getTime()],
]);
});
it('팬덤별 eclipseSchedule 이 있으면 공통 일정 대신 그것을 쓰고 푸시·종료를 다시 계산한다', async () => {
await seedFandom({ warnSeconds: 180, windowSec: 60 });
await aggregateFpi();
await getFirestore().doc(`fandoms/${FANDOM}`)
.set({ eclipseSchedule: [{ hhmm: '09:10', tz: 'Asia/Seoul' }] }, { merge: true });
for (const at of ['09:07', '09:10', '13:00', '21:00', '23:59']) await runEclipseSchedule(kst(DAY, at));
expect(await ids()).toEqual([[`${DAY}-1`, kst(DAY, '09:10').getTime()]]);
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
expect(snap.get('warnAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
expect(snap.get('alertPushedAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
expect(snap.get('endsAt').toMillis()).toBe(kst(DAY, '09:10').getTime() + 60_000);
});
});
describe('v1.5 실패 확정 시에만 시작 FPI 기준으로 1회 차감', () => {
const round = () => getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
async function start() {
await seedFandom();
await aggregateFpi();
await getFirestore().doc(`fandoms/${FANDOM}`).update({ sfpi: 12345 });
await runEclipseSchedule(kst(DAY, '13:00'));
}
const penalty = BASE_TOTAL * EROSION_PERCENT / 100;
it('시작과 방어 중에는 차감·복구 이벤트 없이 시작값만 보관한다', async () => {
await start();
expect((await round().get()).get('fpiAtStart')).toBe(BASE_TOTAL);
await aggregateFpi();
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
});
it('성공은 마감 후에도 무차감이며 재집계가 보너스 FPI를 만들지 않는다', async () => {
await start();
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: `${DAY}-1`, clientSeq: 'win', shakes: TARGET });
await runEclipseSchedule(kst(DAY, '13:30'));
await aggregateActiveEclipses();
await aggregateFpi();
expect((await round().get()).data()).toMatchObject({ result: 'success', appliedAt: null });
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
});
it('새 활동을 보존하고 마감 경계부터 실패 1건만 차감하며 과거 sFPI는 그대로다', async () => {
await start();
const db = getFirestore();
await db.doc(`fandoms/${FANDOM}/fpiEvents/new-activity`).set({
axis: 'bpm', value: 1000000, verified: true, reasonCode: 'SEED',
occurredAt: Timestamp.fromDate(kst(DAY, '13:05')),
});
await aggregateFpi();
await runEclipseSchedule(new Date(kst(DAY, '13:30').getTime() - 1));
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000);
await Promise.all([runEclipseSchedule(kst(DAY, '13:30')), runEclipseSchedule(kst(DAY, '13:30'))]);
expect((await round().get()).data()).toMatchObject({ result: 'failed', penalty });
expect((await round().get()).get('appliedAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
// 이미 확정된 회차를 다시 돌려도 차감은 1회다(G-02).
await runEclipseSchedule(kst(DAY, '13:31'));
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
await aggregateFpi();
await aggregateFpi();
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
expect((await db.doc(`fandoms/${FANDOM}`).get()).get('sfpi')).toBe(12345);
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBe(1);
const event = await db.doc(`fandoms/${FANDOM}/fpiEvents/eclipse-${DAY}-1-penalty`).get();
expect(event.get('occurredAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
expect(event.get('value')).toBe(-penalty);
});
it('차감 기록 저장 실패는 회차 결과와 FPI를 함께 취소하며 재시도로 복구한다', async () => {
await start();
const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => { throw new Error('penalty write failed'); });
try {
await expect(runEclipseSchedule(kst(DAY, '13:30'))).rejects.toThrow('penalty write failed');
} finally { spy.mockRestore(); }
expect((await round().get()).get('status')).toBe('active');
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBeUndefined();
await runEclipseSchedule(kst(DAY, '13:30'));
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
expect((await round().get()).get('result')).toBe('failed');
});
it('결과 집계가 오염되면 실패로 처리하지 않고 무차감 취소한다', async () => {
await start();
await round().collection('shards').doc('invalid').set({ qualifiedParticipants: '1' });
await runEclipseSchedule(kst(DAY, '13:30'));
expect((await round().get()).data()).toMatchObject({ result: 'cancelled_error', status: 'cancelled' });
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
});
it('축별 가중치와 소수 차감에도 시작 FPI의 정확한 비율을 유지한다', async () => {
const db = getFirestore();
const config = db.doc('config/policy');
const original = (await config.get()).data();
try {
await config.set({ fpiWeights: { bpm: 2, health: 0.5, referral: 1, community: 1, contribution: 1 } }, { merge: true });
await seedFandom();
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-bpm`).update({ value: BASE_AXES.bpm! + 1 });
await aggregateFpi();
const startFpi = await currentFpiTotal();
expect(startFpi).toBe(1140002);
await runEclipseSchedule(kst(DAY, '13:00'));
await runEclipseSchedule(kst(DAY, '13:30'));
const loss = startFpi * 0.0025;
expect((await round().get()).get('penalty')).toBe(loss);
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
await aggregateFpi();
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
} finally {
if (original) await config.set(original);
else await config.delete();
}
});
it('마감 전 읽은 FPI 집계가 뒤늦게 저장돼도 확정 차감을 덮어쓰지 않는다', async () => {
await start();
const original = Transaction.prototype.get;
let closed = false;
const spy = vi.spyOn(Transaction.prototype, 'get').mockImplementation(async function (this: Transaction, ...args) {
if (!closed && 'path' in args[0] && args[0].path === `fandoms/${FANDOM}`) {
closed = true;
await runEclipseSchedule(kst(DAY, '13:30'));
}
return original.apply(this, args);
});
try { await aggregateFpi(); } finally { spy.mockRestore(); }
expect(closed).toBe(true);
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
await aggregateFpi();
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
});
});
@@ -0,0 +1,56 @@
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
import { DocumentReference, getFirestore } from 'firebase-admin/firestore';
import { fnPlanetEvolutionRecord } from '../src/fandom/evolution.js';
import { bootAdmin, shutdownAdmin } from './support.js';
const id = 'test-evolution-history';
beforeAll(() => { bootAdmin(); });
afterAll(async () => {
vi.restoreAllMocks();
await getFirestore().recursiveDelete(getFirestore().doc(`fandoms/${id}`));
await shutdownAdmin();
});
async function event(beforeStage: unknown, afterStage: unknown, eventId: string) {
const db = getFirestore();
const ref = db.doc(`fandoms/${id}`);
await ref.set({ ...(beforeStage === undefined ? {} : { stage: beforeStage }) });
const before = (await db.collection('fandoms').where('__name__', '==', id).get()).docs[0]!;
await ref.set({ stage: afterStage });
const after = (await db.collection('fandoms').where('__name__', '==', id).get()).docs[0]!;
return {
specversion: '1.0' as const, id: eventId, source: 'firestore',
type: 'google.cloud.firestore.document.v1.updated', time: new Date().toISOString(),
data: { before, after }, params: { fandomId: id },
location: 'asia-northeast3', project: 'demo-fanuzu', database: '(default)',
namespace: '', document: ref.path,
};
}
it('저장 실패를 재시도하고 동시 재전달에도 확정 진화를 한 번만 남긴다', async () => {
const input = await event(1, 3, 'confirmed/event');
const create = vi.spyOn(DocumentReference.prototype, 'create').mockRejectedValueOnce(new Error('write failed'));
try {
await expect(fnPlanetEvolutionRecord.run(input)).rejects.toThrow('write failed');
} finally {
create.mockRestore();
}
await Promise.all([fnPlanetEvolutionRecord.run(input), fnPlanetEvolutionRecord.run(input)]);
const records = await getFirestore().collection(`fandoms/${id}/stateHistory`).get();
expect(records.size).toBe(1);
const record = records.docs[0]!;
expect(record.get('kind')).toBe('evolution');
expect(record.get('fromStage')).toBe(1);
expect(record.get('toStage')).toBe(3);
expect(record.get('occurredAt').isEqual(input.data.after.updateTime)).toBe(true);
expect(record.get('participantIds')).toBeUndefined();
});
it('동일 Stage·하락·누락·범위 밖 수치를 진화로 기록하지 않는다', async () => {
const history = getFirestore().collection(`fandoms/${id}/stateHistory`);
const count = (await history.get()).size;
for (const [before, after] of [[3, 3], [3, 2], [undefined, 3], [-1, 1], [6, 7], [1, '2']]) {
await fnPlanetEvolutionRecord.run(await event(before, after, `skip-${before}-${after}`));
}
expect((await history.get()).size).toBe(count);
});
@@ -0,0 +1,212 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { distanceMeters, gradeShake } from '../src/quest/verify.js';
import { grantPop, refundPop, spendPop } from '../src/ledger/ledger.js';
/**
* 5~10주차 함수의 불변식 검증.
*
* 화면 없이도 확인할 수 있는 것만 본다 — 판정식, 중복 차단, 환불 무결성.
* 스케줄 함수의 전체 왕복은 배포 뒤 확인한다.
*/
const UID = 'test-ext-user';
const OTHER = 'test-ext-other';
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await wipeUser(OTHER);
});
describe('Music Shake 판정 (E-02)', () => {
it('판정표가 스스로 모순되면 실격이다', () => {
// perfect+good+miss 가 구간 수와 다르면 표가 조작되었거나 깨진 것이다.
expect(gradeShake({ perfect: 10, good: 0, miss: 0, maxCombo: 10 }, 20)).toBe('F');
});
it('빈틈없이 맞히면 S 다', () => {
expect(gradeShake({ perfect: 20, good: 0, miss: 0, maxCombo: 20 }, 20)).toBe('S');
});
it('한 번이라도 놓치면 S 가 아니다', () => {
expect(gradeShake({ perfect: 19, good: 0, miss: 1, maxCombo: 19 }, 20)).toBe('A');
});
it('절반도 못 맞히면 실격이다', () => {
expect(gradeShake({ perfect: 5, good: 2, miss: 13, maxCombo: 3 }, 20)).toBe('F');
});
it('구간이 0이면 실격이다', () => {
expect(gradeShake({ perfect: 0, good: 0, miss: 0, maxCombo: 0 }, 0)).toBe('F');
});
});
describe('방문 거리 (E-06)', () => {
it('같은 좌표는 0 이다', () => {
expect(distanceMeters(37.5, 127.0, 37.5, 127.0)).toBe(0);
});
it('서울 안 두 지점의 거리를 백 미터 단위로 맞춘다', () => {
// 강남역 ↔ 역삼역, 실측 약 1.1km.
const d = distanceMeters(37.4979, 127.0276, 37.5006, 127.0365);
expect(d).toBeGreaterThan(800);
expect(d).toBeLessThan(1400);
});
it('위도 1도는 약 111km 다', () => {
const d = distanceMeters(37.0, 127.0, 38.0, 127.0);
expect(d).toBeGreaterThan(110_000);
expect(d).toBeLessThan(112_000);
});
});
describe('현상금 — 예치·전송·환불 (별첨 1 ④)', () => {
it('예치·전송·환불이 모두 원장을 지나 잔액이 재현된다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna',
});
const deposit = await spendPop({
uid: UID, popType: 'activity', amount: 500,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:1',
});
expect(deposit.balanceAfter).toBe(500);
// 채택 — 답변자에게 지급.
const paid = await grantPop({
uid: OTHER, popType: 'activity', amount: 500,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'accept:q1',
});
expect(paid.balanceAfter).toBe(500);
const db = getFirestore();
const mine = await db.doc(`users/${UID}/wallet/main`).get();
const theirs = await db.doc(`users/${OTHER}/wallet/main`).get();
// 예치한 만큼 정확히 옮겨 갔다 — 만들어지거나 사라진 POP 이 없다.
expect(mine.get('activityBalance') + theirs.get('activityBalance')).toBe(1000);
});
it('기한 만료 환불은 한 번만 나간다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna2',
});
const deposit = await spendPop({
uid: UID, popType: 'activity', amount: 300,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:2',
});
await refundPop({
uid: UID, originalTxId: deposit.txId,
idempotencyKey: 'qnarefund:q2', reasonCode: 'QNA_REFUND',
});
await expect(refundPop({
uid: UID, originalTxId: deposit.txId,
idempotencyKey: 'qnarefund:q2-again', reasonCode: 'QNA_REFUND',
})).rejects.toMatchObject({ details: { code: 'E-POP-03' } });
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(1000);
});
});
describe('캠페인 환불 — 부분 실패를 덮지 않는다 (별첨 1 ⑤)', () => {
it('후원 한 건당 한 번만 돌려준다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp',
});
const donation = await spendPop({
uid: UID, popType: 'activity', amount: 400,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:1',
});
const key = 'refund:donation-1';
const first = await refundPop({
uid: UID, originalTxId: donation.txId,
idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND',
});
// 배치를 다시 돌려도 같은 거래로 판정된다.
const again = await refundPop({
uid: UID, originalTxId: donation.txId,
idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND',
});
expect(again.txId).toBe(first.txId);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(1000);
});
it('환불하면 기여 통계도 되돌아간다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp2',
});
const donation = await spendPop({
uid: UID, popType: 'activity', amount: 400,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:2',
fandomId: 'f1',
});
const db = getFirestore();
expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(400);
await refundPop({
uid: UID, originalTxId: donation.txId,
idempotencyKey: 'refund:donation-2', reasonCode: 'CAMPAIGN_REFUND',
});
expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0);
});
});
describe('영웅의 선물 — 문서 ID 로 중복을 막는다', () => {
it('같은 사람이 두 번 수령하지 못한다', async () => {
const db = getFirestore();
const dropRef = db.doc('fandoms/f1/heroDrops/d1');
await dropRef.set({
totalPop: 1000, perUserCap: 100, remainingPop: 1000,
endsAt: Timestamp.fromMillis(Date.now() + 60_000),
createdAt: FieldValue.serverTimestamp(),
});
const claimRef = dropRef.collection('claims').doc(UID);
await claimRef.create({ uid: UID, amount: 100 });
// 조건문이 아니라 구조로 막힌다 — 동시 요청에서도 뚫리지 않는다.
await expect(claimRef.create({ uid: UID, amount: 100 })).rejects.toThrow();
await dropRef.delete();
await claimRef.delete();
});
});
describe('추천 — 자기 추천과 중복 전환', () => {
it('같은 사람이 같은 코드로 두 번 전환되지 않는다', async () => {
const db = getFirestore();
const ref = db.doc(`referrals/${UID}_CODE1`);
await ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' });
await expect(
ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' }),
).rejects.toThrow();
await ref.delete();
});
});
describe('번역 — 자기검증 제외', () => {
it('자기 마킹은 유효 표에서 빠진다', () => {
const author = 'author';
const markings = [
{ id: author, valid: true },
{ id: 'a', valid: true },
{ id: 'b', valid: false },
{ id: 'c', valid: false },
];
const valid = markings.filter((m) => m.id !== author && m.valid).length;
const invalid = markings.filter((m) => m.id !== author && !m.valid).length;
// 자기 마킹을 세면 2:2 로 통과하지만, 빼면 1:2 로 무효다.
expect(valid).toBe(1);
expect(invalid).toBe(2);
expect(invalid > valid).toBe(true);
});
});
@@ -0,0 +1,99 @@
import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { FieldValue, getFirestore } from 'firebase-admin/firestore';
import { aggregateLive, closeDay, fnFpiAggregate, fnFpiDailySnapshot } from '../src/fandom/fandom.js';
import { Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
const id = 'test-atomic-sfpi';
const run = () => fnFpiDailySnapshot.run({ scheduleTime: new Date().toISOString() });
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
const ref = getFirestore().doc(`fandoms/${id}`);
await getFirestore().recursiveDelete(ref);
await ref.set({ fpiScore: 125, sfpi: 50 });
});
afterAll(async () => {
vi.restoreAllMocks();
await getFirestore().recursiveDelete(getFirestore().doc(`fandoms/${id}`));
await shutdownAdmin();
});
it('누적 쓰기 준비가 실패해도 스냅샷만 남지 않고 재시도로 하루치를 복구한다', async () => {
const increment = vi.spyOn(FieldValue, 'increment').mockImplementationOnce(() => {
throw new Error('injected write failure');
});
await expect(run()).rejects.toThrow('injected write failure');
increment.mockRestore();
const ref = getFirestore().doc(`fandoms/${id}`);
expect((await ref.collection('fpiSnapshots').get()).size).toBe(0);
expect((await ref.get()).get('sfpi')).toBe(50);
await run();
await Promise.all([run(), run()]);
const snapshots = await ref.collection('fpiSnapshots').get();
expect(snapshots.size).toBe(1);
expect(snapshots.docs[0]!.get('fpiScore')).toBe(125);
expect((await ref.get()).get('sfpi')).toBe(175);
});
it('KST 자정 실행은 전날 키로 스냅샷 · DAU 를 남기고 DAU 는 인정 활동의 고유 사용자 수다', async () => {
const ref = getFirestore().doc(`fandoms/${id}`);
const at = (iso: string) => Timestamp.fromDate(new Date(iso));
const events = ref.collection('fpiEvents');
// 9/20 KST 하루: 사용자 a 2건 · b 1건 · 미인정 c · uid 없는 Eclipse 차감 · 경계 밖 d.
await events.add({ uid: 'a', verified: true, axis: 'community', value: 1, occurredAt: at('2026-09-20T00:00:00+09:00') });
await events.add({ uid: 'a', verified: true, axis: 'health', value: 1, occurredAt: at('2026-09-20T12:00:00+09:00') });
await events.add({ uid: 'b', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T23:59:59+09:00') });
await events.add({ uid: 'c', verified: false, axis: 'bpm', value: 1, occurredAt: at('2026-09-20T10:00:00+09:00') });
await events.add({ kind: 'score_adjustment', verified: true, value: -1, occurredAt: at('2026-09-20T17:51:00+09:00') });
await events.add({ uid: 'd', verified: true, axis: 'bpm', value: 1, occurredAt: at('2026-09-21T00:00:00+09:00') });
const result = await closeDay(new Date('2026-09-21T00:00:30+09:00'));
expect(result.day).toBe('2026-09-20');
expect((await ref.collection('fandomDailyStats').doc('2026-09-20').get()).get('dau')).toBe(2);
expect((await ref.collection('fpiSnapshots').doc('2026-09-20').get()).exists).toBe(true);
expect((await ref.collection('fpiSnapshots').doc('2026-09-21').get()).exists).toBe(false);
// 활동 0명인 날도 0 으로 남는다(C-27). 재실행은 스냅샷 · sFPI 를 한 번만 반영한다.
await closeDay(new Date('2026-09-22T00:00:30+09:00'));
await closeDay(new Date('2026-09-22T00:00:30+09:00'));
expect((await ref.collection('fandomDailyStats').doc('2026-09-21').get()).get('dau')).toBe(1);
await closeDay(new Date('2026-09-23T00:00:30+09:00'));
expect((await ref.collection('fandomDailyStats').doc('2026-09-22').get()).get('dau')).toBe(0);
expect((await ref.get()).get('sfpi')).toBe(50 + 125 * 3);
});
it('Eclipse 차감만 있는 팬덤은 FPI 0 아래로 내려가지 않고 랭킹 모집단에 들지 않는다(리뷰 M2)', async () => {
const ref = getFirestore().doc(`fandoms/${id}`);
await ref.set({ stage: 1 }, { merge: true });
await ref.collection('fpiEvents').add({
kind: 'score_adjustment', reasonCode: 'ECLIPSE_EROSION', value: -5, verified: true,
occurredAt: Timestamp.now(),
});
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
const doc = await ref.get();
expect(doc.get('fpiScore')).toBe(0);
expect(doc.get('rankingEligible')).toBe(false);
});
it('LIVE — 최근 창(분) 안에 앱을 쓴 사용자를 fandomIds 마다 세고 빠진 팬덤은 0 으로 내린다(Q-GM-03 A안)', async () => {
const db = getFirestore();
const now = Date.now();
const seed: Array<[string, number, string[]]> = [
['test-live-1', now - 60_000, ['test-live-f1', 'test-live-f2']],
['test-live-2', now - 10 * 60_000, ['test-live-f1']], // 창 밖
['test-live-3', now - 1_000, ['test-live-f1', 'test-live-gone']], // 없는 팬덤은 건너뛴다
];
try {
await db.doc('fandoms/test-live-f1').set({ onlineCount: 0 });
await db.doc('fandoms/test-live-f2').set({ onlineCount: 0 });
await db.doc('fandoms/test-live-f3').set({ onlineCount: 7 }); // 지난 집계의 값
for (const [u, at, fandomIds] of seed) await db.doc(`users/${u}`).set({ lastSeenAt: Timestamp.fromMillis(at), fandomIds });
await aggregateLive(now);
const online = async (id: string) => (await db.doc(`fandoms/${id}`).get()).get('onlineCount');
expect([await online('test-live-f1'), await online('test-live-f2'), await online('test-live-f3')]).toEqual([2, 1, 0]);
expect((await db.doc('fandoms/test-live-gone').get()).exists).toBe(false);
} finally {
for (const [u] of seed) await db.recursiveDelete(db.doc(`users/${u}`));
for (const f of ['f1', 'f2', 'f3']) await db.doc(`fandoms/test-live-${f}`).delete();
}
});
+177
View File
@@ -0,0 +1,177 @@
import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { grantPop, refundPop, reverseGrowth, spendPop } from '../src/ledger/ledger.js';
import { fnAdminGrowthReverse } from '../src/admin/admin.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
const uid = 'test-growth-user';
const a = 'test-growth-a';
const b = 'test-growth-b';
const grant = () => grantPop({ uid, popType: 'activity', amount: 10,
reasonCode: 'QUEST_ATTEND', growthSource: 'quest_reward', idempotencyKey: 'growth:reward' });
const spend = () => spendPop({ uid, popType: 'activity', amount: 10,
reasonCode: 'ZAP_CONTRIBUTION', fandomId: a, idempotencyKey: 'growth:zap' });
async function clean() {
const db = getFirestore();
for (const path of [`users/${uid}`, `fandoms/${a}`, `fandoms/${b}`]) {
await db.recursiveDelete(db.doc(path));
}
}
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
await clean();
const db = getFirestore();
await db.doc(`users/${uid}`).set({ fandomId: a, nickname: '공개팬' });
await db.doc(`fandoms/${a}`).set({ stage: 1, sfpi: 125 });
await db.doc(`fandoms/${b}`).set({ stage: 1, sfpi: 125 });
});
afterAll(async () => { await clean(); await shutdownAdmin(); });
it('보상10+ZAP10=성장20이며 동시 재시도와 대표 팬덤 변경에도 귀속이 고정된다', async () => {
const db = getFirestore();
const results = await Promise.all([grant(), grant()]);
expect(results[0]!.txId).toBe(results[1]!.txId);
await Promise.all([spend(), spend()]);
// 지급 · Zap 뒤에 대표 팬덤을 바꾸고 재시도해도 원 귀속이 유지된다(G-02).
await db.doc(`users/${uid}`).update({ fandomId: b });
await grant();
await spend();
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(20);
expect((await db.doc(`fandoms/${a}`).get()).get('sfpi')).toBe(125);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined();
const events = await db.collection(`users/${uid}/growthEvents`).get();
expect(events.size).toBe(2);
expect(events.docs.map(d => d.get('sourceType')).sort()).toEqual(['quest_reward', 'zap']);
expect(events.docs.every(d => d.get('fandomIdAtPayout') === a && d.id === d.get('sourceEventId'))).toBe(true);
expect((await db.doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(0);
expect((await db.doc(`fandoms/${a}/members/${uid}`).get()).get('contributedPop')).toBe(10);
});
it('일반 광고/관리자 지급/보류 지급/예치성 차감에 성장을 자동 확대하지 않는다', async () => {
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'AD_REWARD', idempotencyKey: 'ad' });
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'admin' });
await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
growthSource: 'quest_reward', pending: true, idempotencyKey: 'pending' });
await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QNA_BOUNTY', fandomId: a, idempotencyKey: 'deposit' });
expect((await getFirestore().collection(`users/${uid}/growthEvents`).get()).size).toBe(0);
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
});
it('지급 트랜잭션 실패는 성장도 남기지 않는다', async () => {
await expect(grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND',
growthSource: 'quest_reward', idempotencyKey: 'abort', alsoWrite: () => { throw new Error('abort'); },
})).rejects.toThrow('abort');
expect((await getFirestore().collection(`users/${uid}/growthEvents`).get()).size).toBe(0);
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
});
it('반환(CAMPAIGN_REFUND · QNA_REFUND)은 성장을 만들지도 되돌리지도 않는다(C-30)', async () => {
const db = getFirestore();
await grantPop({ uid, popType: 'activity', amount: 20, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'seed' });
const fund = await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND',
fandomId: a, idempotencyKey: 'fund' });
const bounty = await spendPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QNA_BOUNTY',
fandomId: a, idempotencyKey: 'bounty' });
await refundPop({ uid, originalTxId: fund.txId, idempotencyKey: 'r1', reasonCode: 'CAMPAIGN_REFUND' });
await refundPop({ uid, originalTxId: bounty.txId, idempotencyKey: 'r2', reasonCode: 'QNA_REFUND' });
const events = await db.collection(`users/${uid}/growthEvents`).get();
expect(events.docs.map((d) => d.get('sourceType'))).toEqual(['funding']);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
});
it('오지급 확정 역분개는 원 이벤트 참조 · 사유 · 음수 수치를 남기고 한 번만 반영된다(C-32)', async () => {
const db = getFirestore();
const paid = await grant();
const reverse = () => reverseGrowth({ uid, sourceEventId: paid.txId, amount: 4,
reason: '중복 지급 확정', actorUid: 'ops-1' });
await Promise.all([reverse(), reverse()]);
const rev = await db.doc(`users/${uid}/growthEvents/rev-${paid.txId}`).get();
expect(rev.get('reversalOf')).toBe(paid.txId);
expect(rev.get('amount')).toBe(-4);
expect(rev.get('reason')).toBe('중복 지급 확정');
expect(rev.get('fandomIdAtPayout')).toBe(a);
// 정상분 6 은 유지된다. 원 행은 그대로다(append-only).
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(6);
expect((await db.doc(`users/${uid}/growthEvents/${paid.txId}`).get()).get('amount')).toBe(10);
// 일부 정정은 금액 오류라 완료(검증 활동)는 그대로다.
expect((await db.collection(`users/${uid}/verifiedCompletions`).where('voided', '==', true).get()).size).toBe(0);
// 원 성장량을 넘는 정정 · 역분개 행의 재역분개는 거부한다.
await expect(reverseGrowth({ uid, sourceEventId: `rev-${paid.txId}`, amount: 1, reason: 'x', actorUid: 'ops-1' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
});
it('Quest 보상 전액 역분개는 그 원장 행의 검증 활동 표식을 무효로 한다 · 일부 정정은 유지(Campaign 개설자격 A안)', async () => {
const db = getFirestore();
const paid = await grant();
await db.doc(`users/${uid}/verifiedCompletions/q_2026-09-28`).set({ questId: 'q', txIds: [paid.txId] });
await reverseGrowth({ uid, sourceEventId: paid.txId, amount: 10, reason: '허위 참여 확정', actorUid: 'ops-1' });
expect(await verifiedQuestCountOf(uid)).toBe(0);
});
it('역분개는 운영자(master/ops)만 부를 수 있다', async () => {
const paid = await grant();
const data = { uid, sourceEventId: paid.txId, amount: 1, reason: '오지급' };
for (const auth of [{ uid, token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fnAdminGrowthReverse.run({ data, auth } as Parameters<typeof fnAdminGrowthReverse.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
expect((await getFirestore().doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
});
it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠페인 팬덤에 귀속된다', async () => {
const db = getFirestore();
const { fundCampaign } = await import('../src/fandom/fandom.js');
const cid = 'test-growth-campaign';
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
await db.doc(`campaigns/${cid}`).set({
status: 'funding', targetPop: 30, raisedPop: 0, fandomId: b,
endsAt: Timestamp.fromMillis(Date.now() + 86_400_000),
});
await grantPop({ uid, popType: 'support', amount: 50, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'fund:seed' });
await grantPop({ uid, popType: 'activity', amount: 50, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'fund:seed-a' });
// 동시 후원 둘이 같은 잔여분(30)을 보고 함께 통과하지 않는다 — 판정이 차감 트랜잭션 안이다.
const settled = await Promise.allSettled([
fundCampaign({ uid, campaignId: cid, clientSeq: 's1', amount: 20 }),
fundCampaign({ uid, campaignId: cid, clientSeq: 's2', amount: 20 }),
]);
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect(String((settled.find((r) => r.status === 'rejected') as PromiseRejectedResult).reason))
.toContain('남은 모금 목표');
expect((await db.doc(`campaigns/${cid}`).get()).get('raisedPop')).toBe(20);
const wallet = (await db.doc(`users/${uid}/wallet/main`).get()).data()!;
expect(wallet.supportBalance).toBe(30);
expect(wallet.activityBalance).toBe(50);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(20);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
const donations = await db.collection(`campaigns/${cid}/donations`).get();
expect(donations.docs.map((d) => d.get('donorUid'))).toEqual([uid]);
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
});
it('fn-pop-spend 는 Zap 만 받고 횟수 · 귀속 팬덤을 서버가 정하며 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
const db = getFirestore();
const { fnPopSpend } = await import('../src/ledger/pop.js');
const run = (data: Record<string, unknown>) => fnPopSpend.run(
{ data, auth: { uid, token: {} } } as Parameters<typeof fnPopSpend.run>[0]);
await db.doc('config/policy').set({ zapPop: 10 }, { merge: true });
await grantPop({ uid, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'zap:seed' });
await expect(run({ popType: 'activity', amount: 10, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'zap:h1' }))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
await expect(run({ popType: 'activity', amount: 5, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:odd' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
// 클라이언트가 count 1e9 · 남의 팬덤 b 를 보내도 서버는 30 ÷ 10 = 3 회 · 대표 팬덤 a 로 적는다.
await run({ popType: 'activity', amount: 30, count: 1e9, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b1' });
const events = await db.collection(`fandoms/${a}/fpiEvents`).get();
expect(events.docs.map((e) => [e.get('axis'), e.get('units')])).toEqual([['contribution', 3]]);
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(0);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(30);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined();
expect((await db.doc(`users/${uid}`).get()).get('userExp')).toBeUndefined();
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
});
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { callerUid, requireRole } from '../src/core/callable.js';
import { fnAdminSetRole, fnConsentRecord } from '../src/account/account.js';
import { fnAdminWrite } from '../src/admin/admin.js';
/** 에뮬레이터 없이 도는 인증 · 권한 관문 검증. 데이터에 닿기 전에 끊겨야 한다. */
const req = (auth?: { uid: string; token: Record<string, unknown> }) =>
({ data: {}, auth } as unknown as CallableRequest);
describe('인증 관문', () => {
it('익명 호출은 E-AUTH-03 으로 끊는다', () => {
expect(() => callerUid(req())).toThrow(expect.objectContaining({ details: { code: 'E-AUTH-03' } }));
});
it('로그인한 호출은 UID 를 돌려준다', () => {
expect(callerUid(req({ uid: 'u1', token: {} }))).toBe('u1');
});
});
describe('운영자 관문', () => {
it('허용 목록에 없는 역할은 E-PERM-01 로 끊는다', () => {
expect(() => requireRole(req({ uid: 'v', token: { role: 'viewer' } }), ['master', 'ops']))
.toThrow(expect.objectContaining({ details: { code: 'E-PERM-01' } }));
});
it('허용된 역할은 그대로 돌려준다', () => {
expect(requireRole(req({ uid: 'o', token: { role: 'ops' } }), ['master', 'ops'])).toBe('ops');
});
it('Admin 쓰기 · 권한 부여는 일반 사용자와 조회 운영자를 데이터 접근 전에 거부한다', async () => {
for (const fn of [fnAdminWrite, fnAdminSetRole]) {
for (const auth of [{ uid: 'user', token: {} }, { uid: 'viewer', token: { role: 'viewer' } }]) {
await expect(fn.run(req(auth) as Parameters<typeof fn.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
}
}
});
it('약관 동의 기록은 익명 호출을 거부한다', async () => {
await expect(fnConsentRecord.run(req() as Parameters<typeof fnConsentRecord.run>[0]))
.rejects.toMatchObject({ details: { code: 'E-AUTH-03' } });
});
});
@@ -0,0 +1,106 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import * as ledger from '../src/ledger/ledger.js';
import { txIdOf } from '../src/core/ids.js';
import { fnHeroDropTrigger, fnHeroDropClaim } from '../src/fandom/herodrop.js';
const uid = 'hero-atomic-test';
const fandomId = 'hero-atomic-test';
async function call(fn: { run(request: never): unknown }, data: Record<string, unknown>) {
return await fn.run({ data, auth: { uid, token: {} } } as never) as Record<string, unknown>;
}
async function clean() {
for (const path of [`users/${uid}`, `fandoms/${fandomId}`])
await getFirestore().recursiveDelete(getFirestore().doc(path));
}
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
await clean();
await getFirestore().doc('config/policy').set({ herodrop: { totalPop: 100, perUserCap: 5, perUserCapPercent: 5, durationSec: 600 } }, { merge: true });
await getFirestore().doc(`users/${uid}`).set({ nickname: '공개팬', displayName: '비공개이름', email: 'private@example.com' });
await ledger.grantPop({ uid, amount: 200, popType: 'support', reasonCode: 'ADMIN_GRANT', idempotencyKey: 'hero-seed' });
});
afterEach(() => vi.restoreAllMocks());
afterAll(async () => { await clean(); await shutdownAdmin(); });
const trigger = () => call(fnHeroDropTrigger, { fandomId, productId: 'gift', clientSeq: '1', heroNickname: '위조닉네임' });
it('발동 재시도는 소진된 Pool과 종료시각을 다시 쓰지 않는다', async () => {
const first = await trigger();
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${first.dropId}`);
const before = (await ref.get()).get('endsAt');
expect((await ref.get()).get('heroNickname')).toBe('공개팬');
expect((await ref.get()).get('email')).toBeUndefined();
await ref.update({ remainingPop: 0, claimedCount: 20 });
await Promise.all([trigger(), trigger()]);
const after = await ref.get();
expect(after.get('remainingPop')).toBe(0);
expect(after.get('claimedCount')).toBe(20);
expect(after.get('endsAt')).toEqual(before);
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(100);
});
it('수령 지급 실패는 Pool과 수령 기록도 남기지 않는다', async () => {
const { dropId } = await trigger();
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
vi.spyOn(ledger, 'grantPop').mockRejectedValueOnce(new Error('grant unavailable'));
await expect(call(fnHeroDropClaim, { fandomId, dropId })).rejects.toThrow('grant unavailable');
expect((await ref.get()).get('remainingPop')).toBe(100);
expect((await ref.collection('claims').get()).size).toBe(0);
});
it('동시 수령과 종료 후 응답 재확인은 같은 지급 결과를 돌려준다', async () => {
const { dropId } = await trigger();
const results = await Promise.all([
call(fnHeroDropClaim, { fandomId, dropId }), call(fnHeroDropClaim, { fandomId, dropId }),
]);
expect(results[0]).toEqual(results[1]);
const ref = getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`);
await ref.update({ endsAt: Timestamp.fromMillis(0) });
expect(await call(fnHeroDropClaim, { fandomId, dropId })).toEqual(results[0]);
expect((await ref.get()).get('remainingPop')).toBe(95);
expect((await ref.get()).get('claimedCount')).toBe(1);
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('activityBalance')).toBe(5);
});
it('이벤트 생성 실패 시 지원 POP 차감도 되돌린다', async () => {
const dropId = txIdOf(`herodrop:trigger:${uid}:1`);
await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).create({ remainingPop: 7 });
await expect(trigger()).rejects.toThrow();
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200);
expect((await getFirestore().doc(`users/${uid}/popTransactions/${dropId}`).get()).exists).toBe(false);
});
it('공개 닉네임이 없으면 비공개 이름이나 이메일로 대체하지 않고 「팬」으로 싣는다', async () => {
await getFirestore().doc(`users/${uid}`).set({ displayName: '비공개이름', email: 'private@example.com' });
const { dropId } = await trigger();
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
expect(drop.get('heroNickname')).toBe('팬');
expect(drop.get('displayName')).toBeUndefined();
expect(drop.get('email')).toBeUndefined();
});
it('기본 5% 상한과 10분을 적용하고 기존 고정 POP 상한은 사용하지 않는다', async () => {
await getFirestore().doc('config/policy').update({ herodrop: { totalPop: 200, perUserCap: 150 } });
const before = Date.now();
const { dropId } = await trigger();
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
expect(drop.get('perUserCap')).toBe(10);
expect(drop.get('perUserCapPercent')).toBe(5);
expect(drop.get('endsAt').toMillis()).toBeGreaterThanOrEqual(before + 600000);
const claim = await call(fnHeroDropClaim, { fandomId, dropId });
expect(claim.claimedPop).toBe(10);
});
it('운영 상한 비율을 이벤트에 고정하고 소수 POP은 상한을 넘겨 올리지 않는다', async () => {
await getFirestore().doc('config/policy').update({ herodrop: {
totalPop: 101, perUserCapPercent: 3, durationSec: 120,
} });
const { dropId } = await trigger();
const drop = await getFirestore().doc(`fandoms/${fandomId}/heroDrops/${dropId}`).get();
expect(drop.get('perUserCap')).toBe(3);
await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': 10 });
expect((await call(fnHeroDropClaim, { fandomId, dropId })).claimedPop).toBe(3);
});
it('유효하지 않은 운영 비율은 차감 전에 거부한다', async () => {
for (const cap of [0, -1, 101, '5']) {
await getFirestore().doc('config/policy').update({ 'herodrop.perUserCapPercent': cap });
await expect(trigger()).rejects.toThrow();
}
expect((await getFirestore().doc(`users/${uid}/wallet/main`).get()).get('supportBalance')).toBe(200);
});
@@ -0,0 +1,114 @@
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
import { getFirestore, Timestamp, Transaction } from 'firebase-admin/firestore';
import { hibernationPatch, hibernationSettings } from '../src/fandom/hibernation.js';
import { fnFpiAggregate } from '../src/fandom/fandom.js';
import { bootAdmin, shutdownAdmin } from './support.js';
const config = { enabled: true, wakeFpi: 20, wakeSeconds: 60 };
const settings = hibernationSettings(config)!;
const day = 86_400_000;
const at = (ms: number) => Timestamp.fromMillis(ms);
let originalConfig: FirebaseFirestore.DocumentData | undefined;
beforeAll(async () => {
bootAdmin();
originalConfig = (await getFirestore().doc('config/hibernation').get()).data();
});
afterAll(async () => {
const db = getFirestore();
const ref = db.doc('config/hibernation');
if (originalConfig) await ref.set(originalConfig);
else await ref.delete();
await db.recursiveDelete(db.doc('fandoms/test-hibernation'));
await shutdownAdmin();
});
it('진입 기본값만 원문에서 가져오고 복귀 설정 누락·잘못된 값은 거부한다', () => {
expect(settings.enterFpi).toBe(10);
expect(settings.enterSeconds).toBe(30 * 86400);
expect(hibernationSettings({})).toBeNull();
for (const invalid of [
{ enabled: true }, { ...config, wakeFpi: 10 },
{ ...config, wakeSeconds: -1 }, { ...config, enterFpi: '10' },
]) expect(() => hibernationSettings(invalid)).toThrow();
expect(hibernationPatch({}, Number.NaN, settings, at(0))).toEqual({});
});
it('30일 연속 미만·경계값·복귀 유지시간·재시도·정책 변경을 구분한다', () => {
let state = hibernationPatch({}, 9, settings, at(0));
state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day - 1)) };
expect(state.isHibernating).toBe(false);
const interrupted = hibernationPatch(state, 10, settings, at(30 * day));
expect(interrupted.hibernationLowSince).toBeNull();
expect(interrupted.isHibernating).toBe(false);
const changed = hibernationPatch(state, 9, { ...settings, enterFpi: 11 }, at(30 * day));
expect(changed.isHibernating).toBe(false);
state = { ...state, ...hibernationPatch(state, 9, settings, at(30 * day)) };
expect(state.isHibernating).toBe(true);
expect(hibernationPatch(state, 99, settings, at(0))).toEqual({});
const noActivity = hibernationPatch(state, 99, settings, at(31 * day));
expect(noActivity.isHibernating).toBe(true);
expect(noActivity.hibernationWakeSince).toBeNull();
const activity = { id: 'new-activity', at: at(31 * day) };
state = { ...state, ...hibernationPatch(state, 20, settings, at(31 * day), activity) };
expect(state.isHibernating).toBe(true);
state = { ...state, ...hibernationPatch(state, 19, settings, at(31 * day + 30_000), activity) };
expect(state.hibernationWakeSince).toBeNull();
state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day), activity) };
expect(state.isHibernating).toBe(true);
state = { ...state, ...hibernationPatch(state, 20, settings, at(32 * day + 60_000), activity) };
expect(state.isHibernating).toBe(false);
});
it('실제 FPI 집계가 휴면을 함께 확정하며 sFPI나 별도 점수를 차감하지 않는다', async () => {
const db = getFirestore();
const ref = db.doc('fandoms/test-hibernation');
await db.doc('config/hibernation').set(config);
await ref.set({
stage: 1, sfpi: 100, fpiScore: 99,
...hibernationPatch({}, 0, settings, at(Date.now() - 31 * day)),
});
const create = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => {
throw new Error('history write failed');
});
try {
await expect(fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }))
.rejects.toThrow('history write failed');
} finally {
create.mockRestore();
}
expect((await ref.get()).get('isHibernating')).toBe(false);
expect((await ref.get()).get('fpiScore')).toBe(99);
expect((await ref.collection('stateHistory').get()).size).toBe(0);
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
const result = await ref.get();
expect(result.get('isHibernating')).toBe(true);
expect(result.get('fpiScore')).toBe(0);
expect(result.get('sfpi')).toBe(100);
expect((await ref.collection('stateHistory').get()).size).toBe(1);
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
expect((await ref.get()).get('hibernationChangedAt').toMillis())
.toBe(result.get('hibernationChangedAt').toMillis());
await db.doc('config/hibernation').set({ ...config, wakeSeconds: 0 });
const event = ref.collection('fpiEvents').doc('wake-activity');
await event.set({ axis: 'contribution', value: 100, verified: true,
occurredAt: Timestamp.now(), reasonCode: 'ECLIPSE_RESTORE' });
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
expect((await ref.get()).get('isHibernating')).toBe(true);
await event.update({ reasonCode: 'QUEST_ACTIVITY' });
await fnFpiAggregate.run({ scheduleTime: new Date().toISOString() });
const awake = await ref.get();
expect(awake.get('isHibernating')).toBe(false);
const history = await ref.collection('stateHistory').orderBy('occurredAt').get();
expect(history.size).toBe(2);
expect(history.docs.map((entry) => entry.get('kind'))).toEqual(['hibernation', 'wake_up']);
expect(history.docs[0]!.get('fpiScore')).toBe(0);
expect(history.docs[1]!.get('sourceEventId')).toBe('wake-activity');
expect(history.docs[1]!.get('settings').wakeSeconds).toBe(0);
expect(awake.get('hibernationWakeEventId')).toBeUndefined();
await Promise.all([
fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }),
fnFpiAggregate.run({ scheduleTime: new Date().toISOString() }),
]);
expect((await ref.collection('stateHistory').get()).size).toBe(2);
expect(awake.get('sfpi')).toBe(100);
});
@@ -0,0 +1,261 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin } from './support.js';
import { fnFocusDailyClaim, fnIapVerify, setReceiptVerifier } from '../src/payment/iap.js';
import {
StoreReceiptVerifier,
type Platform, type ReceiptClaim, type ReceiptHint, type ReceiptVerifier,
} from '../src/payment/receipt.js';
/**
* `fn-iap-verify` 콜러블 — 별첨 1 ⑥ 샌드박스 연결분.
*
* 스토어 왕복은 대역으로 두고, 상품 ID 에 따른 세 갈래(구독 · Hero Gift · 상품 표)와
* 재전송 · 계정 간 영수증 재사용을 본다.
*/
const UID = 'iap-verify-user';
const OTHER = 'iap-verify-other';
const FANDOM = 'iap-verify-fandom';
const GIFT = 'herogift.test.basic';
let claim: Partial<ReceiptClaim> = {};
let lastHint: ReceiptHint | undefined;
class FakeVerifier implements ReceiptVerifier {
async verify(platform: Platform, _receipt: string, hint?: ReceiptHint): Promise<ReceiptClaim> {
lastHint = hint;
return {
platform,
transactionId: 'gpa.1',
productId: 'pop.support.5500',
consumable: true,
purchasedAtMillis: 1_788_000_000_000,
revoked: false,
...claim,
};
}
}
const db = () => getFirestore();
async function call(uid: string, data: Record<string, unknown>) {
return await fnIapVerify.run(
{ data: { platform: 'android', receipt: 'token', ...data }, auth: { uid, token: {} } } as never,
) as Record<string, unknown>;
}
async function codeOf(fn: () => Promise<unknown>): Promise<string | undefined> {
try {
await fn();
} catch (e) {
return (e as { details?: { code?: string } }).details?.code;
}
return undefined;
}
async function clean() {
for (const path of [`users/${UID}`, `users/${OTHER}`, `fandoms/${FANDOM}`, `heroGiftProducts/${GIFT}`]) {
await db().recursiveDelete(db().doc(path));
}
const owners = await db().collection('iapTransactions').get();
await Promise.all(owners.docs.map((d) => d.ref.delete()));
}
beforeAll(() => { bootAdmin(); });
beforeEach(async () => {
claim = {};
setReceiptVerifier(new FakeVerifier());
await clean();
await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: FANDOM });
await db().doc('config/policy').set({
herodrop: { totalPop: 100, perUserCapPercent: 5, durationSec: 600 },
popPackSalesEnabled: false,
focusDailyPop: 40,
}, { merge: true });
await db().doc(`heroGiftProducts/${GIFT}`).set(
{ name: '베이직 드랍', buyerPop: 500, dropPop: 500, active: true });
});
afterEach(() => { setReceiptVerifier(new StoreReceiptVerifier()); });
afterAll(async () => { await clean(); await shutdownAdmin(); });
describe('Hero Gift — 구매자 기여 POP + 팬덤 Drop Pool (B-84)', () => {
it('Catalog 값대로 구매자에게 기여 POP, 소속 팬덤에 같은 규격의 Drop 을 연다', async () => {
claim = { productId: GIFT };
const result = await call(UID, { productId: GIFT });
expect(result.granted).toBe(500);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(500);
const drop = await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get();
expect(drop.get('totalPop')).toBe(500);
expect(drop.get('remainingPop')).toBe(500);
expect(drop.get('perUserCap')).toBe(25);
expect(drop.get('heroNickname')).toBe('영웅팬');
const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get();
expect(purchase.get('state')).toBe('granted');
expect(purchase.get('kind')).toBe('hero_gift');
expect(purchase.get('itemName')).toBe('베이직 드랍');
});
it('같은 구매를 다시 보내도 지급과 Drop 은 한 번이다', async () => {
claim = { productId: GIFT };
const first = await call(UID, { productId: GIFT });
const again = await call(UID, { productId: GIFT });
expect(again.duplicate).toBe(true);
expect(again.txId).toBe(first.txId);
const drops = await db().collection(`fandoms/${FANDOM}/heroDrops`).get();
expect(drops.size).toBe(1);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(500);
});
it('구매자 POP 과 Drop Pool 이 다른 Catalog 값은 지급하지 않는다 (B-84 동일 수량)', async () => {
await db().doc(`heroGiftProducts/${GIFT}`).update({ buyerPop: 100 });
claim = { productId: GIFT };
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-02');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
expect((await db().collection(`fandoms/${FANDOM}/heroDrops`).get()).size).toBe(0);
});
it('지급 전 실패 뒤 재시도는 첫 시도의 팬덤으로 Drop 을 연다', async () => {
claim = { productId: GIFT };
await db().doc(`users/${UID}/purchases/android_gpa.1`).set({ state: 'verified', fandomId: FANDOM });
await db().doc(`users/${UID}`).set({ nickname: '영웅팬', fandomId: 'moved-away' });
const result = await call(UID, { productId: GIFT });
expect((await db().doc(`fandoms/${FANDOM}/heroDrops/${result.dropId}`).get()).exists).toBe(true);
});
it('소속 행성이 없으면 지급도 Drop 도 없이 E-PAY-01(재시도 불가)로 끊는다', async () => {
await db().doc(`users/${UID}`).set({ nickname: '영웅팬' });
claim = { productId: GIFT };
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
});
it('판매를 끈 상품은 지급하지 않는다', async () => {
await db().doc(`heroGiftProducts/${GIFT}`).update({ active: false });
claim = { productId: GIFT };
expect(await codeOf(() => call(UID, { productId: GIFT }))).toBe('E-PAY-01');
});
});
describe('영수증 재사용 · 재전송', () => {
it('다른 계정이 같은 거래를 보내면 E-PAY-01 이고 지급하지 않는다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
await call(UID, {});
expect(await codeOf(() => call(OTHER, {}))).toBe('E-PAY-01');
expect((await db().doc(`users/${OTHER}/wallet/main`).get()).exists).toBe(false);
});
it('환불 요청 뒤 복원 재전송이 상태를 「지급 완료」로 되돌리지 않는다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
await call(UID, {});
const ref = db().doc(`users/${UID}/purchases/android_gpa.1`);
await ref.update({ state: 'refund-requested' });
const again = await call(UID, {});
expect(again.duplicate).toBe(true);
expect((await ref.get()).get('state')).toBe('refund-requested');
});
it('표시용 가격은 저장하되 지급량은 상품 표를 따른다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
const result = await call(UID, { priceAmount: 99, currency: 'KRW', title: '서포터 팩' });
expect(result.granted).toBe(5_500);
const purchase = await db().doc(`users/${UID}/purchases/android_gpa.1`).get();
expect(purchase.get('priceAmount')).toBe(99);
expect(purchase.get('itemName')).toBe('서포터 팩');
expect(purchase.get('method')).toBe('Google Play');
});
});
describe('POP 직접 판매 금지 (B-02)', () => {
it('스위치가 꺼져 있으면(기본) POP 묶음 상품을 E-PAY-01 로 거부하고 지급하지 않는다', async () => {
expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-01');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
expect((await db().doc(`users/${UID}/purchases/android_gpa.1`).get()).exists).toBe(false);
});
it('popPackSalesEnabled 를 켜면 상품 표대로 지급한다', async () => {
await db().doc('config/policy').set({ popPackSalesEnabled: true }, { merge: true });
expect((await call(UID, {})).granted).toBe(5_500);
});
});
describe('Focus 구독 — 광고 제거 (B-04)', () => {
const future = () => Date.now() + 30 * 86_400_000;
it('만료가 미래면 구독을 켜고 만료 시각을 저장하며 POP 은 주지 않는다', async () => {
const expires = future();
claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: expires };
const result = await call(UID, { productId: 'focus.monthly', transactionId: 'gpa.1' });
expect(result.subscription).toBe('active');
const sub = await db().doc(`users/${UID}/subscription/current`).get();
expect(sub.get('adsRemoved')).toBe(true);
expect((sub.get('expiresAt') as Timestamp).toMillis()).toBe(expires);
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
// iOS 영수증에서 이 거래를 고르도록 거래 ID 를 검증기에 넘긴다.
expect(lastHint).toEqual({ productId: 'focus.monthly', transactionId: 'gpa.1' });
});
it('만료가 지났거나 없으면 활성으로 보지 않는다', async () => {
claim = { productId: 'focus.monthly', consumable: false, expiresAtMillis: Date.now() - 1000 };
expect((await call(UID, {})).subscription).toBe('expired');
const sub = await db().doc(`users/${UID}/subscription/current`).get();
expect(sub.get('adsRemoved')).toBe(false);
});
});
describe('Focus 일일 Claim — 일 40 기여 POP · 당일 · 이월 없음 (B-04)', () => {
const claimFocus = async () => await fnFocusDailyClaim.run(
{ data: {}, auth: { uid: UID, token: {} } } as never,
) as Record<string, unknown>;
const subscribe = (expiresAtMillis: number, state = 'active') =>
db().doc(`users/${UID}/subscription/current`).set({
state, adsRemoved: true, expiresAt: Timestamp.fromMillis(expiresAtMillis),
});
it('구독 중이면 하루 한 번 40 기여 POP 을 주고 같은 날 재요청은 첫 결과다', async () => {
await subscribe(Date.now() + 86_400_000);
const first = await claimFocus();
expect(first.granted).toBe(40);
const again = await claimFocus();
expect(again.duplicate).toBe(true);
expect(again.txId).toBe(first.txId);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(40);
const sub = await db().doc(`users/${UID}/subscription/current`).get();
expect(sub.get('focusClaimedDay')).toBe(first.day);
// C-31 — 구독 보너스는 성장 반영을 자동 적용하지 않는다.
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0);
});
it('해지했어도 만료 전이면 받는다', async () => {
await subscribe(Date.now() + 86_400_000, 'canceled');
expect((await claimFocus()).granted).toBe(40);
});
it('구독이 없거나 만료됐으면 E-PAY-03 이고 지급하지 않는다', async () => {
expect(await codeOf(claimFocus)).toBe('E-PAY-03');
await subscribe(Date.now() - 1000);
expect(await codeOf(claimFocus)).toBe('E-PAY-03');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
});
it('운영값 focusDailyPop 을 따른다', async () => {
await db().doc('config/policy').set({ focusDailyPop: 55 }, { merge: true });
await subscribe(Date.now() + 86_400_000);
expect((await claimFocus()).granted).toBe(55);
await db().doc('config/policy').set({ focusDailyPop: 40 }, { merge: true });
});
});
describe('검증 구현이 비어 있는 동안', () => {
it('콜러블도 지급 없이 E-PAY-02 로 끊는다', async () => {
setReceiptVerifier(new StoreReceiptVerifier());
expect(await codeOf(() => call(UID, {}))).toBe('E-PAY-02');
expect((await db().doc(`users/${UID}/wallet/main`).get()).exists).toBe(false);
});
});
+326
View File
@@ -0,0 +1,326 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
grantPop, spendPop, refundPop, reconcileUser,
} from '../src/ledger/ledger.js';
/**
* POP 원장 검증 — 별첨 1 「조건부 적합 제외 항목 ② POP/FPI 데이터 정합성」.
*
* 이 파일이 통과하지 못하면 검수는 조건부 적합이 아니라 부적합이다. 그래서
* 「값이 맞는가」가 아니라 **「불변식이 깨지지 않는가」**를 본다.
*/
const UID = 'test-ledger-user';
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => { await wipeUser(UID); });
describe('지급', () => {
it('lot·원장·지갑을 한 번에 만든다', async () => {
const r = await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'attend:test:2026-09-01',
});
expect(r.balanceAfter).toBe(100);
const db = getFirestore();
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(100);
const lots = await db.collection(`users/${UID}/popLots`).get();
expect(lots.size).toBe(1);
expect(lots.docs[0]!.get('remaining')).toBe(100);
expect(lots.docs[0]!.get('status')).toBe('active');
const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get();
expect(tx.get('amount')).toBe(100);
expect(tx.get('serverVerified')).toBe(true);
});
it('같은 멱등키로 두 번 불러도 한 번만 지급한다', async () => {
const key = 'attend:test:2026-09-02';
const first = await grantPop({
uid: UID, popType: 'activity', amount: 50,
reasonCode: 'QUEST_ATTEND', idempotencyKey: key,
});
const second = await grantPop({
uid: UID, popType: 'activity', amount: 50,
reasonCode: 'QUEST_ATTEND', idempotencyKey: key,
});
expect(second.txId).toBe(first.txId);
const db = getFirestore();
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(50);
const txs = await db.collection(`users/${UID}/popTransactions`).get();
expect(txs.size).toBe(1);
});
it('활동 POP 과 지원 POP 의 잔액을 섞지 않는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'a:1',
});
await grantPop({
uid: UID, popType: 'support', amount: 300,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 's:1',
});
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(100);
expect(wallet.get('supportBalance')).toBe(300);
});
it('보류 지급은 잔액이 아니라 확정 대기에 쌓인다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 70,
reasonCode: 'TRANSLATION', idempotencyKey: 'trn:1', pending: true,
});
const db = getFirestore();
const wallet = await db.doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(0);
expect(wallet.get('pendingActivity')).toBe(70);
const lots = await db.collection(`users/${UID}/popLots`).get();
expect(lots.docs[0]!.get('status')).toBe('pending');
});
});
describe('차감', () => {
it('잔액이 모자라면 E-POP-01 로 끊는다', async () => {
await expect(spendPop({
uid: UID, popType: 'activity', amount: 10,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:none',
})).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
});
it('원장에 음수로 남고 lot 이 줄어든다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:1',
});
const r = await spendPop({
uid: UID, popType: 'activity', amount: 40,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:1',
fandomId: 'fandom-a',
});
expect(r.balanceAfter).toBe(60);
const db = getFirestore();
const tx = await db.doc(`users/${UID}/popTransactions/${r.txId}`).get();
expect(tx.get('amount')).toBe(-40);
const lots = await db.collection(`users/${UID}/popLots`).get();
expect(lots.docs[0]!.get('remaining')).toBe(60);
// 기여 사유의 차감은 통계와 팬덤 멤버 기여도에 함께 반영된다.
const user = await db.doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(40);
// 기여 POP 문턱 등급은 폐기됐다(C-18). 후원 · Zap 은 1 POP 단위로 쪼개 반복할 수 있어
// EXP 를 주지 않는다(리뷰 B1) — EXP 는 Quest 인정 활동에서만 쌓인다.
expect(user.get('tier')).toBeUndefined();
expect(user.get('userExp')).toBeUndefined();
const member = await db.doc(`fandoms/fandom-a/members/${UID}`).get();
expect(member.get('contributedPop')).toBe(40);
});
it('lot 여러 개에 걸쳐 차감하고 소진된 lot 을 닫는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 30,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:a',
});
await grantPop({
uid: UID, popType: 'activity', amount: 30,
reasonCode: 'AD_REWARD', idempotencyKey: 'g:b',
});
const r = await spendPop({
uid: UID, popType: 'activity', amount: 45,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:1',
});
expect(r.balanceAfter).toBe(15);
expect(r.lotRefs).toHaveLength(2);
const lots = await getFirestore()
.collection(`users/${UID}/popLots`).orderBy('grantedAt').get();
const statuses = lots.docs.map((d) => d.get('status'));
expect(statuses).toContain('exhausted');
});
it('같은 멱등키의 재시도는 두 번 차감하지 않는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:2',
});
const key = 'fund:dup';
await spendPop({
uid: UID, popType: 'activity', amount: 25,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key,
});
const again = await spendPop({
uid: UID, popType: 'activity', amount: 25,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: key,
});
expect(again.balanceAfter).toBe(75);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(75);
});
});
describe('환불', () => {
it('원 거래를 고치지 않고 반대 부호의 새 원장을 쓴다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:3',
});
const spent = await spendPop({
uid: UID, popType: 'activity', amount: 60,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:refundable',
});
const refunded = await refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: `refund:${spent.txId}`, reasonCode: 'CAMPAIGN_REFUND',
});
expect(refunded.balanceAfter).toBe(100);
const db = getFirestore();
const original = await db.doc(`users/${UID}/popTransactions/${spent.txId}`).get();
// 금액은 그대로 남아 있어야 한다 — append-only.
expect(original.get('amount')).toBe(-60);
expect(original.get('refundedBy')).toBe(refunded.txId);
const user = await db.doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(0);
});
it('같은 거래를 두 번 환불하지 않는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:4',
});
const spent = await spendPop({
uid: UID, popType: 'activity', amount: 60,
reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:2',
});
await refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: 'refund:once', reasonCode: 'QNA_REFUND',
});
await expect(refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: 'refund:twice', reasonCode: 'QNA_REFUND',
})).rejects.toMatchObject({ details: { code: 'E-POP-03' } });
});
});
describe('대사 — 별첨 1 ② 3자 교차 대조', () => {
it('정상 거래 뒤에는 잔액·lot·원장이 모두 맞는다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:5',
});
await spendPop({
uid: UID, popType: 'activity', amount: 30,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:2',
});
const row = await reconcileUser(UID);
expect(row.ok).toBe(true);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('reconcileStatus')).toBe('ok');
});
it('레벨과 Passport 위조를 서로 오염시키지 않고 따로 잡아낸다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 5_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:rec-tier',
});
await spendPop({
uid: UID, popType: 'activity', amount: 3_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:rec-tier',
});
const failedNames = async () => (await reconcileUser(UID)).checks
.filter((c) => c.expected !== c.actual).map((c) => c.name);
expect(await failedNames()).toEqual([]);
// 원장을 거치지 않은 레벨 위조.
await getFirestore().doc(`users/${UID}`).set({ userLevel: 50 }, { merge: true });
expect(await failedNames()).toEqual(['level-vs-exp']);
// 레벨을 되돌리고 EXP 를 Lv.30 이상으로 올렸는데 Passport 가 SILVER 로 남은 상태(강등 · 누락).
await getFirestore().doc(`users/${UID}`).set({
userExp: 500_000, userLevel: 31, passportTier: 'SILVER',
}, { merge: true });
expect(await failedNames()).toEqual(['passport-monotonic']);
});
it('지갑을 몰래 고치면 대사가 잡아낸다', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 100,
reasonCode: 'QUEST_ATTEND', idempotencyKey: 'g:6',
});
// 원장을 거치지 않은 잔액 변경 — 정확히 이것을 잡아내야 한다.
await getFirestore().doc(`users/${UID}/wallet/main`)
.set({ activityBalance: 999 }, { merge: true });
const row = await reconcileUser(UID);
expect(row.ok).toBe(false);
const failed = row.checks.filter((c) => c.expected !== c.actual);
expect(failed.length).toBeGreaterThan(0);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
// 자동으로 고치지 않는다 — 감지하고 표시만 한다(§3.6).
expect(wallet.get('activityBalance')).toBe(999);
expect(wallet.get('reconcileStatus')).toBe('mismatch');
});
});
describe('User EXP · Level · Passport 갱신 (C-18 · B-65)', () => {
it('기여 차감은 EXP · 등급을 만들지 않는다 · 기여 POP 문턱 승급 없음', async () => {
await grantPop({
uid: UID, popType: 'activity', amount: 5_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier',
});
await spendPop({
uid: UID, popType: 'activity', amount: 3_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier',
});
const user = await getFirestore().doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(3_000);
// 3,000 POP 후원도 EXP 를 만들지 않는다(C-18 기여 POP 치환 금지 · 리뷰 B1).
expect(user.get('userExp')).toBeUndefined();
expect(user.get('passportTier')).toBeUndefined();
expect(user.get('tier')).toBeUndefined();
});
it('환불은 기여 통계만 되돌리고 EXP · Passport 는 내리지 않는다', async () => {
await getFirestore().doc(`users/${UID}`).set({ passportTier: 'GOLD' }, { merge: true });
await grantPop({
uid: UID, popType: 'activity', amount: 5_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:tier2',
});
const spent = await spendPop({
uid: UID, popType: 'activity', amount: 3_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:tier2',
});
await refundPop({
uid: UID, originalTxId: spent.txId,
idempotencyKey: 'refund:tier2', reasonCode: 'CAMPAIGN_REFUND',
});
const user = await getFirestore().doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(0);
expect(user.get('userExp')).toBeUndefined();
// 레벨이 낮아도 이미 GOLD 였으므로 강등하지 않는다.
expect(user.get('passportTier')).toBe('GOLD');
});
it('기여가 아닌 차감은 EXP 를 건드리지 않는다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 1_000,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:tier3',
});
await spendPop({
uid: UID, popType: 'support', amount: 500,
reasonCode: 'HERO_DROP_TRIGGER', idempotencyKey: 'spend:tier3',
});
const user = await getFirestore().doc(`users/${UID}`).get();
expect(user.get('userExp')).toBeUndefined();
});
});
@@ -0,0 +1,195 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
grantPop, reconcileUser, refundPop, refundSplit, spendSplit, type PopType,
} from '../src/ledger/ledger.js';
/**
* 겸용 사용처 분할 차감 — 정책 원장 B-06 · 설계서 §3.2.
* 일반(activity) 우선, 부족분만 기여(support). 두 재화면 원장 2건 · 한 트랜잭션 · 멱등키 하나.
*/
const UID = 'test-ledger-split';
const db = () => getFirestore();
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await db().recursiveDelete(db().doc('fandoms/f-split'));
});
async function seed(general: number, contribution: number) {
if (general > 0) {
await grantPop({ uid: UID, popType: 'activity', amount: general, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'seed:g' });
}
if (contribution > 0) {
await grantPop({ uid: UID, popType: 'support', amount: contribution, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'seed:c' });
}
}
async function wallet() {
const w = await db().doc(`users/${UID}/wallet/main`).get();
return { general: w.get('activityBalance') as number, contribution: w.get('supportBalance') as number };
}
async function spendRows() {
const snap = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get();
return snap.docs.map((d) => ({ popType: d.get('popType') as PopType, amount: d.get('amount') as number }))
.sort((a, b) => a.popType.localeCompare(b.popType));
}
const spend = (amount: number, key = 'fee:1') =>
spendSplit({ uid: UID, amount, reasonCode: 'CAMPAIGN_FEE', idempotencyKey: key });
describe('spendSplit', () => {
it('일반 잔액이 딱 맞으면 일반 1건만', async () => {
await seed(100, 50);
const parts = await spend(100);
expect(parts.map((p) => [p.popType, p.amount])).toEqual([['activity', 100]]);
expect(await wallet()).toEqual({ general: 0, contribution: 50 });
expect(await spendRows()).toEqual([{ popType: 'activity', amount: -100 }]);
});
it('일반만 있으면 일반에서', async () => {
await seed(300, 0);
await spend(120);
expect((await wallet()).general).toBe(180);
expect(await spendRows()).toEqual([{ popType: 'activity', amount: -120 }]);
});
it('기여만 있으면 기여에서', async () => {
await seed(0, 300);
const parts = await spend(120);
expect(parts.map((p) => p.popType)).toEqual(['support']);
expect((await wallet()).contribution).toBe(180);
expect(await spendRows()).toEqual([{ popType: 'support', amount: -120 }]);
});
it('섞이면 일반을 다 쓰고 부족분만 기여 — 원장 2건 · 같은 멱등키 · lotOrder 규범', async () => {
await seed(30, 200);
const parts = await spend(100);
expect(parts.map((p) => [p.popType, p.amount, p.balanceAfter]))
.toEqual([['activity', 30, 0], ['support', 70, 130]]);
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
expect(await spendRows()).toEqual([
{ popType: 'activity', amount: -30 }, { popType: 'support', amount: -70 },
]);
const rows = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'spend').get();
for (const r of rows.docs) {
expect(r.get('idempotencyKey')).toBe('fee:1');
expect(r.get('lotOrder')).toBe('general-first-fifo');
}
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('합계가 모자라면 E-POP-01 이고 원장 행이 없다', async () => {
await seed(30, 20);
await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
expect(await spendRows()).toEqual([]);
expect(await wallet()).toEqual({ general: 30, contribution: 20 });
});
it('같은 멱등키 재시도는 어느 행도 두 번 쓰지 않는다(동시 포함)', async () => {
await seed(30, 200);
const first = await spend(100);
const [again, ...racing] = await Promise.all([spend(100), spend(100), spend(100)]);
expect(again!.map((p) => p.txId).sort()).toEqual(first.map((p) => p.txId).sort());
for (const r of racing) expect(r.length).toBe(2);
expect(await spendRows()).toHaveLength(2);
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
});
it('기여 POP 으로 낸 예치 · 등록비는 성장 · FPI 원천이 아니다(C-30)', async () => {
await db().doc(`users/${UID}`).set({ fandomId: 'f-split', primaryFandomId: 'f-split' });
await seed(0, 500);
await spend(100, 'fee:growth');
await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:growth' });
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(0);
expect((await db().collection('fandoms/f-split/fpiEvents').get()).size).toBe(0);
expect((await db().doc('fandoms/f-split').get()).get('growthPop')).toBeUndefined();
});
});
describe('lot 부족', () => {
it('기여 몫의 lot 이 모자라면 E-POP-02 · 행 0건 · 지갑은 트랜잭션 밖에서 mismatch 표시', async () => {
await seed(30, 200);
const lot = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'support').get()).docs[0]!;
await lot.ref.update({ remaining: 10 });
await expect(spend(100)).rejects.toMatchObject({ details: { code: 'E-POP-02' } });
expect(await spendRows()).toEqual([]);
const w = await db().doc(`users/${UID}/wallet/main`).get();
expect(w.get('reconcileStatus')).toBe('mismatch');
expect(w.get('activityBalance')).toBe(30);
const general = (await db().collection(`users/${UID}/popLots`).where('popType', '==', 'activity').get()).docs[0]!;
expect(general.get('remaining')).toBe(30);
});
});
describe('refundSplit', () => {
it('섞인 차감을 행마다 원래 타입으로 돌리고 재시도는 한 번뿐이다', async () => {
await seed(30, 200);
const parts = await spend(100);
const refund = () => refundSplit({
uid: UID, originalTxIds: parts.map((p) => p.txId),
idempotencyKey: 'feeRefund:1', reasonCode: 'CAMPAIGN_REFUND',
});
const first = await refund();
const again = await refund();
expect(again.map((r) => r.txId).sort()).toEqual(first.map((r) => r.txId).sort());
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get();
expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort())
.toEqual([['activity', 30], ['support', 70]]);
for (const p of parts) {
expect((await db().doc(`users/${UID}/popTransactions/${p.txId}`).get()).get('refundedBy')).toBeTruthy();
}
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('이미 다른 경로로 환불된 행은 그 환불을 쓰고, 같은 원거래 ID 는 한 번만 센다', async () => {
await seed(30, 200);
const parts = await spend(100);
const [general, contribution] = parts.map((p) => p.txId);
const earlier = await refundPop({
uid: UID, originalTxId: contribution!, idempotencyKey: 'other-path', reasonCode: 'CAMPAIGN_REFUND',
});
const results = await refundSplit({
uid: UID, originalTxIds: [general!, contribution!, general!],
idempotencyKey: 'feeRefund:reuse', reasonCode: 'CAMPAIGN_FEE_REFUND',
});
expect(results).toHaveLength(2);
expect(results[1]!.txId).toBe(earlier.txId);
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
const refunds = await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get();
expect(refunds.size).toBe(2);
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('QNA_BOUNTY 분할 예치 → 환불 → 대사가 맞는다(누적 기여도 0 으로 돌아온다)', async () => {
await seed(30, 200);
const parts = await spendSplit({ uid: UID, amount: 100, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:mixed' });
expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(100);
expect((await reconcileUser(UID)).ok).toBe(true);
await refundSplit({
uid: UID, originalTxIds: parts.map((p) => p.txId), idempotencyKey: 'qnarefund:mixed', reasonCode: 'QNA_REFUND',
});
expect((await db().doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0);
expect(await wallet()).toEqual({ general: 30, contribution: 200 });
expect((await reconcileUser(UID)).ok).toBe(true);
});
it('원거래 하나라도 없으면 아무 행도 쓰지 않는다', async () => {
await seed(30, 200);
const parts = await spend(100);
await expect(refundSplit({
uid: UID, originalTxIds: [parts[0]!.txId, 'missing'],
idempotencyKey: 'feeRefund:bad', reasonCode: 'CAMPAIGN_REFUND',
})).rejects.toMatchObject({ details: { code: 'E-POP-02' } });
expect((await db().collection(`users/${UID}/popTransactions`).where('type', '==', 'refund').get()).size).toBe(0);
expect(await wallet()).toEqual({ general: 0, contribution: 130 });
});
});
+168
View File
@@ -0,0 +1,168 @@
import { afterAll, beforeAll, expect, it, vi } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import {
cleanupCampaignComment, cleanupPost, fnCampaignCommentCount, fnCampaignCommentLikeCount, fnPostCommentCount, fnPostLikeCount, recountPost,
} from '../src/inside/lounge.js';
import { bootAdmin, shutdownAdmin } from './support.js';
/** 라운지 좋아요 · 댓글 수는 하위 문서를 다시 세어 적는다 — 재전달에도 부풀지 않는다. */
const POST = 'test-lounge-post';
beforeAll(() => { bootAdmin(); });
afterAll(async () => {
const db = getFirestore();
for (const id of [POST, 'test-lounge-gone', 'test-lounge-stale', 'test-lounge-del']) {
await db.recursiveDelete(db.doc(`posts/${id}`));
}
await db.recursiveDelete(db.doc('campaigns/test-cmp-comments'));
await shutdownAdmin();
});
/** 트리거 이벤트 모양. `before`/`after` 는 문서 스냅샷 흉내만 낸다. */
function written(document: string, params: Record<string, string>, data?: unknown) {
return {
specversion: '1.0', id: `e-${Math.random()}`, source: 'firestore',
type: 'google.cloud.firestore.document.v1.written', time: new Date().toISOString(),
data, params, location: 'asia-northeast3', project: 'demo-fanuzu',
database: '(default)', namespace: '', document,
} as never;
}
const snap = (fields: Record<string, unknown> | null) => ({
exists: fields !== null,
get: (k: string) => fields?.[k],
});
it('좋아요 수는 몇 번을 돌아도 하위 문서 수와 같다', async () => {
const db = getFirestore();
await db.doc(`posts/${POST}`).set({ likeCount: 99, commentCount: 99 });
for (const uid of ['a', 'b', 'c']) {
await db.doc(`posts/${POST}/likes/${uid}`).set({ createdAt: new Date() });
}
await recountPost(POST, 'likes');
await recountPost(POST, 'likes');
expect((await db.doc(`posts/${POST}`).get()).get('likeCount')).toBe(3);
await db.doc(`posts/${POST}/likes/a`).delete();
// 트리거 배선 — 경로 파라미터로 글을 찾는다.
await fnPostLikeCount.run(written(`posts/${POST}/likes/a`, { postId: POST, uid: 'a' }));
expect((await db.doc(`posts/${POST}`).get()).get('likeCount')).toBe(2);
});
it('먼저 센 값이 나중에 센 값을 덮지 않는다', async () => {
const db = getFirestore();
const ref = db.doc('posts/test-lounge-stale');
// 이미 「미래」 시점에 센 값이 적혀 있다 — 지금 센 값은 그보다 오래됐다.
await ref.set({ likeCount: 7, likeCountAt: Timestamp.fromMillis(Date.now() + 60_000) });
await db.doc('posts/test-lounge-stale/likes/a').set({ createdAt: new Date() });
await recountPost('test-lounge-stale', 'likes');
expect((await ref.get()).get('likeCount')).toBe(7);
});
it('댓글 수는 보이는 댓글만 세고, 본문만 고친 수정은 다시 세지 않는다', async () => {
const db = getFirestore();
await db.doc(`posts/${POST}/comments/c1`).set({ visibility: 'visible' });
await db.doc(`posts/${POST}/comments/c2`).set({ visibility: 'visible' });
await db.doc(`posts/${POST}/comments/c3`).set({ visibility: 'hidden' });
await recountPost(POST, 'comments');
expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(2);
await db.doc(`posts/${POST}`).update({ commentCount: 42 });
const params = { postId: POST, commentId: 'c1' };
const doc = `posts/${POST}/comments/c1`;
await fnPostCommentCount.run(written(doc, params, {
before: snap({ visibility: 'visible', body: 'a' }),
after: snap({ visibility: 'visible', body: 'b' }),
}));
expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(42);
// 숨김 전환은 다시 센다.
await db.doc(`posts/${POST}/comments/c2`).update({ visibility: 'hidden' });
await fnPostCommentCount.run(written(doc, { postId: POST, commentId: 'c2' }, {
before: snap({ visibility: 'visible' }),
after: snap({ visibility: 'hidden' }),
}));
expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(1);
});
it('지워진 글은 다시 만들지 않는다', async () => {
const db = getFirestore();
await db.doc('posts/test-lounge-gone/likes/a').set({ createdAt: new Date() });
await recountPost('test-lounge-gone', 'likes');
expect((await db.doc('posts/test-lounge-gone').get()).exists).toBe(false);
});
it('글이 지워지면 좋아요 · 댓글과 작성자의 그 글 사진을 지운다', async () => {
const db = getFirestore();
await db.doc('posts/test-lounge-del/likes/a').set({ createdAt: new Date() });
await db.doc('posts/test-lounge-del/comments/c').set({ visibility: 'visible' });
const deleteFiles = vi.fn(async () => {});
await cleanupPost('test-lounge-del', 'alice', deleteFiles);
expect((await db.collection('posts/test-lounge-del/likes').get()).size).toBe(0);
expect((await db.collection('posts/test-lounge-del/comments').get()).size).toBe(0);
expect(deleteFiles).toHaveBeenCalledWith('users/alice/lounge/test-lounge-del-');
});
it('캠페인 응원 게시판 댓글 수도 보이는 댓글만 다시 세어 캠페인 문서에 적는다', async () => {
const db = getFirestore();
const cmp = 'campaigns/test-cmp-comments';
await db.doc(cmp).set({ status: 'funding', commentCount: 99 });
await db.doc(`${cmp}/comments/a`).set({ visibility: 'visible' });
await db.doc(`${cmp}/comments/b`).set({ visibility: 'hidden' });
const params = { campaignId: 'test-cmp-comments', commentId: 'a' };
await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, {
before: snap(null),
after: snap({ visibility: 'visible' }),
}));
expect((await db.doc(cmp).get()).get('commentCount')).toBe(1);
// 본문만 고친 수정은 다시 세지 않는다.
await db.doc(cmp).update({ commentCount: 42 });
await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, {
before: snap({ visibility: 'visible', body: 'x' }),
after: snap({ visibility: 'visible', body: 'y' }),
}));
expect((await db.doc(cmp).get()).get('commentCount')).toBe(42);
// 삭제는 다시 센다.
await db.doc(`${cmp}/comments/a`).delete();
await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, {
before: snap({ visibility: 'visible' }),
after: snap(null),
}));
expect((await db.doc(cmp).get()).get('commentCount')).toBe(0);
});
it('응원 게시판 댓글 좋아요 수는 댓글 문서에 다시 세어 적고 댓글 수는 건드리지 않는다', async () => {
const db = getFirestore();
const cmp = 'campaigns/test-cmp-comments';
const comment = `${cmp}/comments/liked`;
await db.doc(cmp).set({ status: 'funding', commentCount: 5 });
await db.doc(comment).set({ visibility: 'visible', likeCount: 99 });
for (const uid of ['a', 'b']) {
await db.doc(`${comment}/likes/${uid}`).set({ createdAt: new Date() });
}
const params = { campaignId: 'test-cmp-comments', commentId: 'liked', uid: 'a' };
await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, params));
await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, params));
expect((await db.doc(comment).get()).get('likeCount')).toBe(2);
expect((await db.doc(cmp).get()).get('commentCount')).toBe(5);
});
it('응원 게시판 댓글이 지워지면 하위 좋아요를 지우고 댓글 문서를 되살리지 않는다', async () => {
const db = getFirestore();
const comment = 'campaigns/test-cmp-comments/comments/gone';
await db.doc(comment).set({ visibility: 'visible', likeCount: 2 });
for (const uid of ['a', 'b']) await db.doc(`${comment}/likes/${uid}`).set({ createdAt: new Date() });
await db.doc(comment).delete();
await cleanupCampaignComment('test-cmp-comments', 'gone');
await cleanupCampaignComment('test-cmp-comments', 'gone');
expect((await db.collection(`${comment}/likes`).get()).size).toBe(0);
// 좋아요 삭제로 뒤늦게 도는 수 트리거도 없는 댓글을 만들지 않는다.
await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, {
campaignId: 'test-cmp-comments', commentId: 'gone', uid: 'a',
}));
expect((await db.doc(comment).get()).exists).toBe(false);
});
+175
View File
@@ -0,0 +1,175 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop, spendPop } from '../src/ledger/ledger.js';
import {
popForProduct, PRODUCT_POP, SUBSCRIPTION_PRODUCTS,
type Platform, type ReceiptClaim, type ReceiptVerifier,
} from '../src/payment/receipt.js';
import { StoreReceiptVerifier } from '../src/payment/receipt.js';
/**
* 결제 검증 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」.
*
* **스토어 왕복은 여기서 재지 않는다.** 자격증명이 스토어 계정에 묶여 있어
* 샌드박스 실행은 계정이 열린 뒤다. 대신 **금액 결정 · 멱등 · 환불 회수**를
* 지금 검증한다 — 계정이 열려도 이 세 가지가 틀리면 검수를 통과하지 못한다.
*/
const UID = 'test-pay-user';
class FakeVerifier implements ReceiptVerifier {
constructor(private readonly claim: Partial<ReceiptClaim> = {}) {}
async verify(platform: Platform, _receipt: string): Promise<ReceiptClaim> {
return {
platform,
transactionId: 'tx-1',
productId: 'pop.support.5500',
consumable: true,
purchasedAtMillis: 1_788_000_000_000,
revoked: false,
...this.claim,
};
}
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => { await wipeUser(UID); });
describe('상품 표 — 금액은 서버가 정한다', () => {
it('등록된 상품만 지급 수량을 낸다', () => {
expect(popForProduct('pop.support.5500')).toBe(5_500);
});
it('표에 없는 상품 ID 는 E-PAY-01 로 끊는다', () => {
// 위조이거나 스토어 상품 등록과 코드가 어긋난 상태다. 둘 다 지급하면 안 된다.
expect(() => popForProduct('pop.support.999999'))
.toThrowError(expect.objectContaining({ details: { code: 'E-PAY-01' } }));
});
it('구독 상품은 POP 표에 없다', () => {
for (const product of SUBSCRIPTION_PRODUCTS) {
expect(PRODUCT_POP[product]).toBeUndefined();
}
});
it('모든 상품 수량이 양의 정수다', () => {
for (const amount of Object.values(PRODUCT_POP)) {
expect(Number.isInteger(amount)).toBe(true);
expect(amount).toBeGreaterThan(0);
}
});
});
describe('검증 구현이 비어 있는 동안', () => {
it('자격증명 없이 부르면 지급하지 않고 E-PAY-02 로 끊는다', async () => {
// 「나중에 채우자」로 비워 두면 검증 없는 지급 경로가 열린 채 배포된다.
await expect(new StoreReceiptVerifier().verify('ios', 'receipt'))
.rejects.toMatchObject({ details: { code: 'E-PAY-02' } });
});
});
describe('지급 — 스토어 거래 ID 가 멱등키다', () => {
it('같은 거래로 두 번 지급하지 않는다', async () => {
const claim = await new FakeVerifier().verify('ios', 'r');
const key = `iap:${claim.platform}:${claim.transactionId}`;
const first = await grantPop({
uid: UID, popType: 'support', amount: popForProduct(claim.productId),
reasonCode: 'IAP_PURCHASE', idempotencyKey: key,
});
const second = await grantPop({
uid: UID, popType: 'support', amount: popForProduct(claim.productId),
reasonCode: 'IAP_PURCHASE', idempotencyKey: key,
});
expect(second.txId).toBe(first.txId);
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(5_500);
});
it('플랫폼이 다르면 다른 거래로 본다', async () => {
const ios = await new FakeVerifier().verify('ios', 'r');
const android = await new FakeVerifier().verify('android', 'r');
expect(`iap:${ios.platform}:${ios.transactionId}`)
.not.toBe(`iap:${android.platform}:${android.transactionId}`);
});
it('지원 POP 으로만 들어가고 활동 POP 은 그대로다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-a',
});
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(5_500);
expect(wallet.get('activityBalance')).toBe(0);
});
});
describe('환불 회수 — 별첨 1 ⑥ 예외 처리', () => {
it('잔액이 남아 있으면 회수한다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-b',
});
const clawed = await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-b',
});
expect(clawed.balanceAfter).toBe(0);
});
it('이미 써 버려 잔액이 모자라면 음수 잔액을 만들지 않는다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-c',
});
await spendPop({
uid: UID, popType: 'support', amount: 5_000,
reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:pay-1',
});
// 사용자는 쓴 적 없는 마이너스를 보면 안 되고, 원장 합과 잔액이 어긋나서도 안 된다.
await expect(spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-c',
})).rejects.toMatchObject({ details: { code: 'E-POP-01' } });
const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('supportBalance')).toBe(500);
});
it('회수도 멱등이다 — 스토어 알림은 중복 도착한다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-d',
});
const key = 'clawback:ios:tx-d';
await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key,
});
const again = await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: key,
});
expect(again.balanceAfter).toBe(0);
});
it('회수는 기여 통계를 건드리지 않는다', async () => {
await grantPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: 'iap:ios:tx-e',
});
await spendPop({
uid: UID, popType: 'support', amount: 5_500,
reasonCode: 'ADMIN_CLAWBACK', idempotencyKey: 'clawback:ios:tx-e',
});
const user = await getFirestore().doc(`users/${UID}`).get();
// 결제 회수는 팬덤 기여가 아니다.
expect(user.get('totalContributedPop')).toBeUndefined();
});
});
@@ -0,0 +1,240 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnFandomJoin, fnFandomLeave, fnNebulaCreate, fnPlanetCreate, fnPrimaryFandomSet, isValidNebulaName,
} from '../src/account/account.js';
/** 성운 생성 → 가입 · 대표 팬덤 → 행성 탄생 — 원장 B-08 · B-09 · B-10 · C-15 · C-50. */
const UID = 'test-planet-create';
const OTHER = 'test-planet-create-other';
const db = () => getFirestore();
/** 이 파일이 만든 것만 지운다 — 같은 에뮬레이터를 쓰는 다른 테스트의 문서를 건드리지 않는다. */
const created = { fandoms: new Set<string>(), names: new Set<string>() };
async function call(fn: { run(request: never): unknown }, data: Record<string, unknown>, uid = UID) {
const out = (await fn.run({ data, auth: { uid, token: {} } } as unknown as never)) as Record<string, unknown>;
if (fn === fnNebulaCreate) {
created.fandoms.add(out.fandomId as string);
created.names.add(String(data.name).toLowerCase());
}
return out;
}
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
return (error as { details?: { code?: string } }).details?.code ?? (error as { code?: string }).code ?? '?';
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
const user = async (uid = UID) => (await db().doc(`users/${uid}`).get()).data() ?? {};
const fandom = async (id: string) => (await db().doc(`fandoms/${id}`).get()).data() ?? {};
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
afterEach(async () => {
for (const uid of [UID, OTHER]) {
await wipeUser(uid);
await db().recursiveDelete(db().collection(`users/${uid}/nebulaCreates`));
await db().recursiveDelete(db().collection(`users/${uid}/memberships`));
await db().doc(`users/${uid}`).delete();
}
for (const id of created.fandoms) await db().recursiveDelete(db().doc(`fandoms/${id}`));
for (const name of created.names) await db().doc(`fandomNames/${name}`).delete();
created.fandoms.clear();
created.names.clear();
});
describe('성운 생성', () => {
it('Stage0 공용 성운을 만들고 만든 사람이 창립 멤버 #001 · 대표 팬덤이 된다', async () => {
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'BTS RM' });
const f = await fandom(fandomId as string);
expect(f).toMatchObject({ name: 'BTS RM', stage: 0, founderUid: UID, memberCount: 1, growthPop: 0 });
expect(f.nebulaDeadline.toMillis() - Date.now()).toBeGreaterThan(6.9 * 86_400_000);
expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).get('memberNo')).toBe(1);
expect((await db().doc(`users/${UID}/memberships/${fandomId}`).get()).exists).toBe(true);
expect(primaryChanged).toBe(true);
expect(await user()).toMatchObject({ primaryFandomId: fandomId, fandomId });
});
it('대표 변경 30일 대기 중이면 성운 · 멤버십만 만들고 대표는 그대로 둔다(C-15)', async () => {
await db().doc(`users/${UID}`).set({
primaryFandomId: 'old', fandomId: 'old', primaryChangedAt: Timestamp.fromMillis(Date.now() - 86_400_000),
});
const { fandomId, primaryChanged } = await call(fnNebulaCreate, { name: 'Cooldown' });
expect(primaryChanged).toBe(false);
expect(await user()).toMatchObject({ primaryFandomId: 'old', fandomId: 'old' });
expect((await db().doc(`fandoms/${fandomId}/members/${UID}`).get()).exists).toBe(true);
});
it('같은 이름(대소문자 무시)은 E-FDM-01 · 하루 두 번째 생성은 E-FDM-02', async () => {
await call(fnNebulaCreate, { name: 'Aurora' });
expect(await codeOf(() => call(fnNebulaCreate, { name: 'aurora' }, OTHER))).toBe('E-FDM-01');
expect(await codeOf(() => call(fnNebulaCreate, { name: 'Borealis' }))).toBe('E-FDM-02');
});
it('이름 규칙 — 2~20자 · 한글/영문/숫자/공백 · 연속·앞뒤 공백 불가', () => {
expect(isValidNebulaName('방탄 소년단1')).toBe(true);
for (const bad of ['A', ' AB', 'AB ', 'A B', 'AB!', 'x'.repeat(21)]) {
expect(isValidNebulaName(bad)).toBe(false);
}
});
});
describe('가입 · 탈퇴 · 대표 팬덤', () => {
it('두 번 가입해도 한 번만 센다 · 탈퇴도 한 번만 내린다 · 첫 가입 팬덤이 대표가 된다', async () => {
const { fandomId } = await call(fnNebulaCreate, { name: 'Joinable' });
const id = fandomId as string;
expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(true);
expect((await call(fnFandomJoin, { fandomId: id }, OTHER)).joined).toBe(false);
expect(await fandom(id)).toMatchObject({ memberCount: 2, memberSeq: 2 });
expect((await db().doc(`fandoms/${id}/members/${OTHER}`).get()).get('memberNo')).toBe(2);
expect(await user(OTHER)).toMatchObject({ primaryFandomId: id });
expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(true);
expect((await call(fnFandomLeave, { fandomId: id }, OTHER)).left).toBe(false);
expect((await fandom(id)).memberCount).toBe(1);
expect(await user(OTHER)).toMatchObject({ primaryFandomId: null, fandomId: '' });
expect((await db().doc(`users/${OTHER}/memberships/${id}`).get()).exists).toBe(false);
// LIVE 집계용 가입 목록 비정규화 — 가입 · 탈퇴 트랜잭션이 함께 유지한다.
expect((await user(OTHER)).fandomIds).toEqual([]);
expect((await user()).fandomIds).toEqual([id]);
});
it('없는 팬덤 가입 · 가입하지 않은 팬덤을 대표로 지정은 거부한다', async () => {
expect(await codeOf(() => call(fnFandomJoin, { fandomId: 'missing-fandom' }))).toBe('E-PERM-01');
const { fandomId } = await call(fnNebulaCreate, { name: 'NotMine' }, OTHER);
expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId }))).toBe('E-PERM-01');
});
it('대표 변경은 무료 · 30일 안 재변경은 E-FDM-04 · 대기가 지나면 허용', async () => {
const a = (await call(fnNebulaCreate, { name: 'Primary A' }, OTHER)).fandomId as string;
await call(fnFandomJoin, { fandomId: a });
// 대표가 없던 계정의 첫 지정(가입)은 변경이 아니라 대기 시각을 찍지 않는다.
expect((await user()).primaryChangedAt).toBeUndefined();
// 성운 생성 = 대표 변경(A → B). 여기서 대기가 시작된다.
const b = (await call(fnNebulaCreate, { name: 'Primary B' })).fandomId as string;
expect(await user()).toMatchObject({ primaryFandomId: b });
expect((await user()).primaryChangedAt).toBeInstanceOf(Timestamp);
expect(await codeOf(() => call(fnPrimaryFandomSet, { fandomId: a }))).toBe('E-FDM-04');
await db().doc(`users/${UID}`).update({ primaryChangedAt: Timestamp.fromMillis(Date.now() - 31 * 86_400_000) });
expect((await call(fnPrimaryFandomSet, { fandomId: a })).changed).toBe(true);
expect(await user()).toMatchObject({ primaryFandomId: a, fandomId: a });
});
});
describe('창립자 탈퇴 · 대표 팬덤 자동 지정(Q-PN-03 · Q-PN-04 권고안)', () => {
const setPolicy = (patch: Record<string, unknown>) => db().doc('config/policy').set(patch, { merge: true });
afterEach(async () => {
await db().doc('config/policy').set({
nebulaFounderLeaveBlocked: FieldValue.delete(), primaryOnLeave: FieldValue.delete(),
}, { merge: true });
});
it('성운 창립자는 탄생 전 탈퇴 E-FDM-05 · 탄생 뒤에는 탈퇴 가능 · 설정을 끄면 막지 않는다', async () => {
const id = (await call(fnNebulaCreate, { name: 'Founder Stay' })).fandomId as string;
expect(await codeOf(() => call(fnFandomLeave, { fandomId: id }))).toBe('E-FDM-05');
expect((await fandom(id)).memberCount).toBe(1);
await db().doc(`fandoms/${id}`).update({ stage: 1 });
expect((await call(fnFandomLeave, { fandomId: id })).left).toBe(true);
const other = (await call(fnNebulaCreate, { name: 'Founder Go' }, OTHER)).fandomId as string;
await setPolicy({ nebulaFounderLeaveBlocked: false });
expect((await call(fnFandomLeave, { fandomId: other }, OTHER)).left).toBe(true);
});
/** A(대표) · B · C 에 가입 — 가입 시각은 B < C 로 못박는다. */
async function joinThree(): Promise<[string, string, string]> {
const ids: string[] = [];
for (const name of ['leave-a', 'leave-b', 'leave-c']) {
// 성운 생성은 하루 1회라 정식 행성 문서를 직접 둔다.
const id = `test-planet-create-${name}`;
await db().doc(`fandoms/${id}`).set({ name, stage: 1, founderUid: OTHER, memberCount: 0 });
created.fandoms.add(id);
await call(fnFandomJoin, { fandomId: id });
ids.push(id);
}
const [a, b, c] = ids as [string, string, string];
await db().doc(`users/${UID}/memberships/${b}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 2 * 86_400_000) });
await db().doc(`users/${UID}/memberships/${c}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 86_400_000) });
expect(await user()).toMatchObject({ primaryFandomId: a });
return [a, b, c];
}
it('대표 팬덤을 떠나면 가장 최근 가입 팬덤이 대표 · 대기에 세지 않는다 · 남은 팬덤이 없으면 비운다', async () => {
const [a, b, c] = await joinThree();
expect((await call(fnFandomLeave, { fandomId: a })).primaryFandomId).toBe(c);
expect(await user()).toMatchObject({ primaryFandomId: c, fandomId: c });
expect((await user()).primaryChangedAt).toBeUndefined();
// 자동 지정 직후에도 사용자가 바로 바꿀 수 있다.
expect((await call(fnPrimaryFandomSet, { fandomId: b })).changed).toBe(true);
await call(fnFandomLeave, { fandomId: c }); // 대표가 아니면 대표는 그대로
expect(await user()).toMatchObject({ primaryFandomId: b });
await call(fnFandomLeave, { fandomId: b });
expect(await user()).toMatchObject({ primaryFandomId: null, fandomId: '' });
});
it('primaryOnLeave — earliest 는 가장 먼저 가입한 팬덤 · none 은 비워 둔다', async () => {
const [a, b] = await joinThree();
await setPolicy({ primaryOnLeave: 'earliest' });
await call(fnFandomLeave, { fandomId: a });
expect(await user()).toMatchObject({ primaryFandomId: b });
await setPolicy({ primaryOnLeave: 'none' });
await call(fnFandomLeave, { fandomId: b });
expect(await user()).toMatchObject({ primaryFandomId: null });
});
});
describe('행성 탄생', () => {
async function nebula(members: number, pop: number): Promise<string> {
const { fandomId } = await call(fnNebulaCreate, { name: `N${members}x${pop}` });
await db().doc(`fandoms/${fandomId}`).update({ memberCount: members, growthPop: pop });
return fandomId as string;
}
const pick = (fandomId: string) => ({ fandomId, planetType: 'shape2', colorKey: 'gold' });
it('없는 성운은 거부 · 한쪽 조건만 채우면 E-FDM-03(B-10)', async () => {
expect(await codeOf(() => call(fnPlanetCreate, pick('missing-nebula')))).toBe('E-PERM-01');
const id = await nebula(9, 5_000);
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
await db().doc(`fandoms/${id}`).update({ memberCount: 10, growthPop: 999 });
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
});
it('7일 목표는 설정이 켜졌을 때만 막는다(nebulaConversionDeadlineEnforced)', async () => {
const id = await nebula(10, 1_000);
await db().doc(`fandoms/${id}`).update({ nebulaDeadline: Timestamp.fromMillis(Date.now() - 1_000) });
await db().doc('config/policy').set({ nebulaConversionDeadlineEnforced: true }, { merge: true });
try {
expect(await codeOf(() => call(fnPlanetCreate, pick(id)))).toBe('E-FDM-03');
} finally {
await db().doc('config/policy').update({ nebulaConversionDeadlineEnforced: FieldValue.delete() });
}
await call(fnPlanetCreate, pick(id));
expect((await fandom(id)).stage).toBe(1);
});
it('10명 + 1,000 POP 이면 Stage1 이 되고 형태·컬러는 한 번만 저장된다(C-50)', async () => {
const id = await nebula(10, 1_000);
expect(await codeOf(() => call(fnPlanetCreate, pick(id), OTHER))).toBe('E-PERM-01');
await call(fnPlanetCreate, pick(id));
expect(await fandom(id)).toMatchObject({ stage: 1, planetType: 'shape2', colorKey: 'gold' });
expect((await db().doc(`fandoms/${id}/planets/planet`).get()).get('ownerUid')).toBe(UID);
// 두 번째 생성(= 외형 변경 시도)은 거부된다.
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: 'red' }))).toBe('E-POP-03');
});
it('팔레트 밖 형태·색은 받지 않는다', async () => {
const id = await nebula(10, 1_000);
expect(await codeOf(() => call(fnPlanetCreate, { ...pick(id), colorKey: '#123456' })))
.toBe('invalid-argument');
});
});
@@ -0,0 +1,78 @@
import { afterAll, beforeAll, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { fnPlanetHistory } from '../src/fandom/history.js';
import { bootAdmin, shutdownAdmin } from './support.js';
const fandomId = 'history-test';
const uid = 'history-reader';
const call = (data: Record<string, unknown>, user = uid) =>
fnPlanetHistory.run({ data, ...(user ? { auth: { uid: user, token: {} } } : {}) } as never);
beforeAll(async () => {
bootAdmin();
const db = getFirestore();
for (let i = 1; i <= 22; i++) {
await db.doc(`fandoms/${fandomId}/stateHistory/${i}`).set({
kind: 'evolution', fromStage: 1, toStage: 2,
occurredAt: Timestamp.fromMillis(i * 1000), sourceEventId: 'private-source',
settings: { private: true },
});
}
await db.doc(`users/${uid}/growthEvents/one`).set({
fandomIdAtPayout: fandomId, amount: 10, sourceType: 'zap', reversalOf: null,
createdAt: Timestamp.fromMillis(10_000),
});
});
afterAll(async () => {
const db = getFirestore();
await db.recursiveDelete(db.doc(`fandoms/${fandomId}`));
await db.recursiveDelete(db.doc(`users/${uid}`));
await shutdownAdmin();
});
it('자신의 과거 기여만 확인하며 다른 사용자 ID나 내부 기록을 노출하지 않는다', async () => {
const result = await call({ fandomId });
expect(result.items).toHaveLength(20);
expect(result.items[0]!.participationConfirmed).toBe(true);
expect(result.items.at(-1)!.participationConfirmed).toBe(false);
expect(JSON.stringify(result)).not.toContain('private');
const stranger = await call({ fandomId, uid }, 'other-user');
expect(stranger.items.every((item) => item.participationConfirmed === false)).toBe(true);
});
it('커서 페이지가 겹치지 않으며 인증·경로·커서 오류를 거부한다', async () => {
const first = await call({ fandomId });
const second = await call({ fandomId, cursor: first.nextCursor });
expect(second.items).toHaveLength(2);
expect(second.nextCursor).toBeNull();
expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(22);
await expect(call({ fandomId }, '')).rejects.toThrow();
await expect(call({ fandomId: '../users/private' })).rejects.toThrow();
await expect(call({ fandomId, cursor: 'missing' })).rejects.toThrow();
});
it('수량이 잘못된 기록은 개인 참여 증거로 쓰지 않는다', async () => {
await getFirestore().doc(`users/${uid}/growthEvents/one`).update({ amount: '10' });
const result = await call({ fandomId });
expect(result.items.every((item) => item.participationConfirmed === false)).toBe(true);
});
it('내 활동은 호출자와 지급 당시 팬덤으로 제한하고 다른 팬덤 커서를 거부한다', async () => {
const events = getFirestore().collection(`users/${uid}/growthEvents`);
await events.doc('one').update({ amount: 10 });
for (const [index, sourceType] of ['quest_reward', 'company_ad_bonus', 'zap', 'funding'].entries()) {
await events.doc(`activity-${index}`).set({
fandomIdAtPayout: fandomId, sourceType, amount: 10 + index, reversalOf: null,
createdAt: Timestamp.fromMillis(30_000 + index), sourceEventId: 'private-ledger',
});
}
await events.doc('other-fandom').set({ fandomIdAtPayout: 'other-fandom', sourceType: 'zap',
amount: 999, createdAt: Timestamp.fromMillis(40_000) });
const result = await call({ fandomId, scope: 'personal' });
expect(result.items).toHaveLength(5);
expect(result.items[0]!.kind).toBe('funding');
expect(result.items[0]!.amount).toBe(13);
expect(JSON.stringify(result)).not.toContain('private-ledger');
expect((await call({ fandomId, scope: 'personal', uid }, 'other-user')).items).toEqual([]);
await expect(call({ fandomId, scope: 'personal', cursor: 'other-fandom' })).rejects.toThrow();
await expect(call({ fandomId, scope: 'all-users' })).rejects.toThrow();
});
+282
View File
@@ -0,0 +1,282 @@
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<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(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);
});
});
+308
View File
@@ -0,0 +1,308 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import {
countAnswer, expireBounties, fnAnswerAccept, fnQuestionCreate, MAX_REFUND_ATTEMPTS,
} from '../src/inside/qna.js';
/**
* 현상금 Q&A 왕복 — 별첨 1 ④ 「예치·전송·환불 원장 정합성 오류 0건」.
* 핸들러를 `CallableFunction.run` 으로 그대로 돌린다(quest_e2e 와 같은 방식).
*/
const ASKER = 'test-qna-asker';
const ANSWERER = 'test-qna-answerer';
type Reply = Record<string, unknown>;
async function call(
fn: { run(request: never): unknown },
uid: string,
data: Record<string, unknown>,
): Promise<Reply> {
const request = { data, auth: { uid, token: {} } } as unknown as never;
return (await fn.run(request)) as Reply;
}
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const e = error as { details?: { code?: string }; code?: string };
return e.details?.code ?? e.code ?? String(error);
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
async function balance(uid: string): Promise<number> {
const w = await getFirestore().doc(`users/${uid}/wallet/main`).get();
return (w.get('activityBalance') as number | undefined) ?? 0;
}
let seq = 0;
async function ask(bountyPop: number): Promise<string> {
const reply = await call(fnQuestionCreate, ASKER, {
title: '질문', body: '본문', bountyPop, clientSeq: `s${++seq}-${Date.now()}`,
});
return reply.questionId as string;
}
async function answer(questionId: string, uid: string): Promise<string> {
const ref = await getFirestore().collection(`questions/${questionId}/answers`).add({
authorUid: uid, authorName: uid, body: '답', accepted: false,
});
return ref.id;
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(ASKER);
await wipeUser(ANSWERER);
const old = await getFirestore().collection('questions').where('authorUid', '==', ASKER).get();
await Promise.all(old.docs.map((d) => getFirestore().recursiveDelete(d.ref)));
await getFirestore().doc(`users/${ASKER}`).set({ displayName: '김실명', nickname: '질문자', fandomId: 'f-qna' });
await grantPop({
uid: ASKER, popType: 'activity', amount: 1000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:qna:${Date.now()}`,
});
});
describe('fn-question-create', () => {
it('예치와 질문이 함께 서고, 목록이 읽는 팬덤·표시명이 복제된다', async () => {
const id = await ask(300);
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('open');
expect(q.get('fandomId')).toBe('f-qna');
expect(q.get('authorName')).toBe('질문자');
});
it('닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => {
await getFirestore().doc(`users/${ASKER}`).update({ nickname: FieldValue.delete() });
const q = await getFirestore().doc(`questions/${await ask(300)}`).get();
expect(q.get('authorName')).toBe('팬');
expect(q.get('bountyPop')).toBe(300);
expect(await balance(ASKER)).toBe(700);
});
it('제목 · 본문 길이 상한을 서버가 끊는다', async () => {
const run = (title: string, body: string) => call(fnQuestionCreate, ASKER, {
title, body, bountyPop: 100, clientSeq: `len-${Date.now()}`,
});
expect(await codeOf(() => run('가'.repeat(101), '본문'))).toBe('invalid-argument');
expect(await codeOf(() => run('제목', '가'.repeat(5_001)))).toBe('invalid-argument');
expect(await balance(ASKER)).toBe(1000);
});
it('현상금 100 POP 미만은 서버가 거부한다', async () => {
expect(await codeOf(() => ask(99))).toBe('invalid-argument');
expect(await balance(ASKER)).toBe(1000);
});
it('현상금 하한은 config/policy.qnaMinBountyPop 이 덮는다', async () => {
const policy = getFirestore().doc('config/policy');
await policy.set({ qnaMinBountyPop: 200 }, { merge: true });
try {
expect(await codeOf(() => ask(150))).toBe('invalid-argument');
expect(await ask(200)).toBeTruthy();
} finally {
await policy.set({ qnaMinBountyPop: FieldValue.delete() }, { merge: true });
}
});
it('일반이 모자라면 부족분만 기여 POP 으로 예치하고, 만료 환불은 각 타입으로 돌아간다(B-39)', async () => {
await grantPop({
uid: ASKER, popType: 'support', amount: 500,
reasonCode: 'IAP_PURCHASE', idempotencyKey: `s:qna:${Date.now()}`,
});
const support = async () =>
((await getFirestore().doc(`users/${ASKER}/wallet/main`).get()).get('supportBalance') as number);
const id = await ask(1200);
expect(await balance(ASKER)).toBe(0);
expect(await support()).toBe(300);
const q = await getFirestore().doc(`questions/${id}`).get();
expect((q.get('bountyTxIds') as string[]).length).toBe(2);
await q.ref.update({ expiresAt: Timestamp.fromMillis(Date.now() - 1000) });
await expireBounties();
await expireBounties();
expect((await q.ref.get()).get('status')).toBe('expired');
expect(await balance(ASKER)).toBe(1000);
expect(await support()).toBe(500);
const refunds = await getFirestore().collection(`users/${ASKER}/popTransactions`)
.where('reasonCode', '==', 'QNA_REFUND').get();
expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort())
.toEqual([['activity', 1000], ['support', 200]]);
});
it('분할 전 옛 질문(bountyTxId 한 건)도 만료 환불된다', async () => {
const id = await ask(300);
const ref = getFirestore().doc(`questions/${id}`);
const [txId] = (await ref.get()).get('bountyTxIds') as string[];
await ref.update({
bountyTxIds: FieldValue.delete(), bountyTxId: txId,
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
await expireBounties();
expect((await ref.get()).get('status')).toBe('expired');
expect(await balance(ASKER)).toBe(1000);
});
it('잔액이 모자라면 질문이 남지 않는다', async () => {
expect(await codeOf(() => ask(5000))).toBe('E-POP-01');
const left = await getFirestore().collection('questions')
.where('authorUid', '==', ASKER).where('bountyPop', '==', 5000).get();
expect(left.empty).toBe(true);
});
});
describe('fn-answer-accept', () => {
it('채택하면 현상금이 답변자에게 일반 POP 으로 가고 번복되지 않는다', async () => {
const id = await ask(400);
const a = await answer(id, ANSWERER);
await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a });
expect(await balance(ANSWERER)).toBe(400);
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('accepted');
const ans = await getFirestore().doc(`questions/${id}/answers/${a}`).get();
expect(ans.get('accepted')).toBe(true);
const b = await answer(id, 'someone-else');
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: b })))
.toBe('E-QNA-03');
expect(await balance(ANSWERER)).toBe(400);
});
it('자기 답변은 채택할 수 없다(E-QNA-01)', async () => {
const id = await ask(200);
const mine = await answer(id, ASKER);
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: mine })))
.toBe('E-QNA-01');
});
it('질문자가 아니면 채택할 수 없다', async () => {
const id = await ask(200);
const a = await answer(id, ANSWERER);
expect(await codeOf(() => call(fnAnswerAccept, ANSWERER, { questionId: id, answerId: a })))
.toBe('E-PERM-01');
});
});
describe('fn-question-bounty-expire', () => {
it('기한이 지나면 원타입으로 한 번만 환불되고, 이후 채택은 E-QNA-02', async () => {
const id = await ask(500);
const a = await answer(id, ANSWERER);
await getFirestore().doc(`questions/${id}`).update({
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
const early = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
.catch((e: { details?: { code?: string }; message: string }) => e);
expect(early).toMatchObject({ details: { code: 'E-QNA-02' } });
expect((early as { message: string }).message).toContain('환불될 예정');
await expireBounties();
await expireBounties();
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('expired');
expect(await balance(ASKER)).toBe(1000);
expect(await balance(ANSWERER)).toBe(0);
const late = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
.catch((e: { message: string }) => e);
expect((late as { message: string }).message).toContain('환불되었습니다');
});
});
/** 두 사람 사이의 원장 행 수(초기 지급 제외). 예치 1 + 채택 또는 환불 1 이어야 한다. */
async function movements(): Promise<string[]> {
const out: string[] = [];
for (const uid of [ASKER, ANSWERER]) {
const rows = await getFirestore().collection(`users/${uid}/popTransactions`).get();
for (const r of rows.docs) {
const code = r.get('reasonCode') as string;
if (code !== 'ADMIN_GRANT') out.push(code);
}
}
return out.sort();
}
describe('채택과 만료 환불의 경합', () => {
it('동시에 돌아도 채택 또는 환불 중 정확히 하나만 원장을 움직인다', async () => {
const id = await ask(500);
const a = await answer(id, ANSWERER);
// 기한 직전에 둘이 겹치는 상황 — 만료 시각을 막 지난 값으로 둔다.
await getFirestore().doc(`questions/${id}`).update({
expiresAt: Timestamp.fromMillis(Date.now() + 300),
});
await new Promise((r) => setTimeout(r, 300));
await Promise.allSettled([
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
expireBounties(),
]);
const moved = (await movements()).filter((c) => c !== 'QNA_BOUNTY');
expect(moved).toHaveLength(1);
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
});
it('기한 전 동시 채택은 한쪽만 지급된다', async () => {
const id = await ask(500);
const a = await answer(id, ANSWERER);
await Promise.allSettled([
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
expireBounties(),
]);
expect(await movements()).toEqual(['QNA_BOUNTY', 'QNA_REWARD']);
expect(await balance(ANSWERER)).toBe(500);
});
});
describe('환불 실패 처리', () => {
it(`${MAX_REFUND_ATTEMPTS}번 실패하면 refund_failed 로 멈추고 운영 로그를 남긴다`, async () => {
const id = await ask(200);
// 원거래를 가리키는 ID 를 깨뜨려 환불이 계속 실패하게 한다.
await getFirestore().doc(`questions/${id}`).update({
bountyTxIds: ['missing-tx'],
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
for (let i = 0; i < MAX_REFUND_ATTEMPTS + 1; i++) await expireBounties();
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('status')).toBe('refund_failed');
expect(q.get('refundAttempts')).toBe(MAX_REFUND_ATTEMPTS);
const logs = await getFirestore().collection('adminActionLogs')
.where('targetPath', '==', `questions/${id}`).get();
expect(logs.size).toBe(1);
});
it('질문자 정보가 없으면 바로 refund_failed 다', async () => {
const id = await ask(200);
await getFirestore().doc(`questions/${id}`).update({
bountyTxIds: [],
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
});
await expireBounties();
expect((await getFirestore().doc(`questions/${id}`).get()).get('status')).toBe('refund_failed');
});
});
describe('답변 수 트리거', () => {
it('같은 답변이 재전달되어도 한 번만 센다', async () => {
const id = await ask(100);
const a = await answer(id, ANSWERER);
expect(await countAnswer(id, a)).toBe(true);
expect(await countAnswer(id, a)).toBe(false);
const b = await answer(id, 'someone-else');
await Promise.all([countAnswer(id, b), countAnswer(id, b)]);
const q = await getFirestore().doc(`questions/${id}`).get();
expect(q.get('answerCount')).toBe(2);
});
it('질문이 없으면 건너뛴다', async () => {
expect(await countAnswer('no-such-question', 'x')).toBe(false);
});
});
@@ -0,0 +1,628 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, FieldValue, GeoPoint, Timestamp } from 'firebase-admin/firestore';
import type { CallableRequest } from 'firebase-functions/v2/https';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdminCompletionVoid } from '../src/admin/admin.js';
import { fnAdReward, fnQuestAttend, fnQuestVerifySteps } from '../src/quest/quest.js';
import { fnQuestVerifyMusicShake, fnQuestVerifyPlace } from '../src/quest/verify.js';
import { fnCampaignFund } from '../src/fandom/fandom.js';
/**
* 퀘스트 E2E — 별첨 1 ③ 「퀘스트별 수행 → 검증 → 보상」 판정 절차.
*
* 별첨 1 ② 완료 시나리오 1 「활동 보상 → 무료 POP 지급 → 원장 기록 → 행성 성장 반영」과
* ③ 「만보기·위치·출석 → 검증 → 보상」을 한 파일에서 끝까지 돈다. 화면 없이 도는 것은
* **서버가 판정 주체**이기 때문이다 — 앱은 증거만 싣는다.
*
* 확인하는 것
* 1. 출석 1회 → 원장 행 1건 → 잔액 증가 → 같은 날 2회차 거부(E-POP-03)
* 2. 걸음 미달(E-QST-01) · 비정상(E-QST-02) · 완주 지급 · 당일 재지급 거부
* 3. 방문 반경 밖(E-QST-03) · 모의 위치(E-QST-02) · 인증 수단 없음 · 정상 지급 · 재사용 차단
* 4. 받은 POP 을 캠페인에 쓰면 **화면이 읽는 필드**가 움직인다
* (`users.totalContributedPop` · `users.userExp/userLevel` · `members.contributedPop`)
*/
const UID = 'test-quest-e2e';
const FANDOM = 'fandom-quest-e2e';
const PLACE = 'place-quest-e2e';
/// 앱이 읽는 것과 같은 **퀘스트 문서 ID**. 서버가 진행 문서를 이 ID 로 써야 한다.
const ATTEND_QUEST = 'daily-checkin';
const STEPS_QUEST = 'daily-steps';
const PLACE_QUEST = 'place-visit';
/** 카탈로그 코드만 꺼낸다. 화면도 상태가 아니라 이 코드로 분기한다. */
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const details = (error as { details?: { code?: string } }).details;
return details?.code ?? `(코드 없음) ${(error as Error).message}`;
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 서버 응답 한 건. 값의 형이 섞여 있어 판정에 필요한 것만 꺼내 쓴다. */
type Reply = Record<string, unknown>;
/**
* 배포하지 않고 핸들러를 그대로 실행한다(`CallableFunction.run`).
*
* 배포·HTTP 왕복 없이도 **판정 로직 전체**가 도는 경로다. 인증 문맥만 흉내 내고
* 나머지는 실제 코드가 그대로 돈다 — 에뮬레이터의 Firestore 를 쓰므로 트랜잭션과
* 문서 ID 멱등까지 실제 동작이다.
*/
async function call(
fn: { run(request: never): unknown },
data: Record<string, unknown> = {},
): Promise<Reply> {
const request = { data, auth: { uid: UID, token: {} } } as unknown as never;
return (await fn.run(request)) as Reply;
}
/** 문서 하나를 꺼낸다. 없으면 그 자리에서 끊는다 — 뒤에서 undefined 로 새지 않게. */
function only<T>(docs: T[]): T {
const first = docs[0];
if (first === undefined) throw new Error('문서가 없습니다.');
return first;
}
const db = () => getFirestore();
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
for (const sub of ['activityLogs']) {
const snap = await db().collection(`users/${UID}/${sub}`).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
const proofs = await db().collection('placeProofs').get();
await Promise.all(proofs.docs.map((d) => d.ref.delete()));
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
await Promise.all(events.docs.map((d) => d.ref.delete()));
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 });
// 운영값은 문서로 둔다 — 코드에 박으면 회신마다 재배포다(설계서 §10).
await db().doc('config/policy').set({
attendPop: 10, stepsGoal: 10_000, stepsPop: 50, adRewardPop: 10, adMultiplier: 2, adDailyLimit: 5,
// 정책 원장 **B-26** 확정 구간. 운영값이라 코드가 아니라 문서에 둔다.
stepTiers: [
{ steps: 1_000, pop: 5 },
{ steps: 5_000, pop: 6 },
{ steps: 10_000, pop: 7 },
{ steps: 15_000, pop: 8 },
],
}, { merge: true });
// 퀘스트 정의. **일일 한도의 유일한 원본**이며(설계서 §2.8) 진행 문서 ID 도 이 ID 다.
await db().doc(`quests/${ATTEND_QUEST}`).set({
type: 'attend', title: '오늘의 출석', rewardPop: 10, dailyLimit: 1, active: true,
});
await db().doc(`quests/${STEPS_QUEST}`).set({
type: 'walk', title: '만보기 챌린지', rewardPop: 0, dailyLimit: 1, active: true,
});
// 위치 퀘스트는 장소를 가리킨다. 서버는 이 문서의 장소와 요청 장소가 같아야 지급한다.
for (const id of [PLACE_QUEST, 'place-quest-once', 'place-quest-other', 'place-quest-reuse']) {
await db().doc(`quests/${id}`).set({ type: 'location', placeId: PLACE, dailyLimit: 1 });
}
await db().doc('quests/place-quest-repeat').set({
type: 'location', placeId: PLACE, repeat: { allowed: true, intervalHours: 24, maxCount: 2 },
});
await db().doc(`places/${PLACE}`).set({
name: '홍대 팝업스토어',
geo: new GeoPoint(37.5563, 126.9236),
// 별첨 B **B-27** 기본 반경 100m. Admin 운영값이라 문서에 둔다.
radiusM: 100,
rewardPop: 100,
verifyMethods: ['gps', 'qr'],
});
});
describe('출석 — S-H-ATTEND (fn-quest-attend)', () => {
it('없는 퀘스트 · 출석이 아닌 퀘스트 ID 로는 지급하지 않는다(리뷰 H2)', async () => {
expect(await codeOf(() => call(fnQuestAttend, { questId: 'no-such-quest' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestAttend, { questId: STEPS_QUEST }))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('참여 1회로 원장 1행·잔액·진행 카운터가 함께 생긴다', async () => {
const result = await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(result.rewarded).toBe(10);
expect(result.balanceAfter).toBe(10);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(only(ledger.docs).get('reasonCode')).toBe('QUEST_ATTEND');
// 검증 없는 지급 경로가 없다는 것을 원장 자체에 남긴다 — 별첨 1 ② 판정 근거.
expect(only(ledger.docs).get('serverVerified')).toBe(true);
expect(only(ledger.docs).get('amount')).toBe(10);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(10);
});
it('앱이 읽는 경로(퀘스트 문서 ID)에 진행이 완료로 남는다', async () => {
await call(fnQuestAttend, { questId: ATTEND_QUEST });
// 앱은 `users/{uid}/questProgress/{퀘스트 문서 ID}` 를 읽는다. 서버가 유형 이름에
// 쓰면 지급은 되는데 화면은 영원히 「시작하기」로 남는다.
const progress = await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get();
expect(progress.exists).toBe(true);
expect(progress.get('questId')).toBe(ATTEND_QUEST);
expect(progress.get('count')).toBe(1);
expect(progress.get('rewardedPop')).toBe(10);
// 일일 한도는 `quests/{id}.dailyLimit` 이 정한다. 1 이면 여기서 완료로 보인다.
const quest = await db().doc(`quests/${ATTEND_QUEST}`).get();
expect(progress.get('count')).toBeGreaterThanOrEqual(quest.get('dailyLimit') as number);
// 유형 이름으로 쓰던 예전 자리에는 아무것도 남지 않는다.
const legacy = await db().doc(`users/${UID}/questProgress/attend`).get();
expect(legacy.exists).toBe(false);
});
it('같은 날 두 번째 참여는 서버가 거부한다', async () => {
await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(await codeOf(() => call(fnQuestAttend, { questId: ATTEND_QUEST })))
.toBe('E-POP-03');
// 거부된 호출이 원장을 늘리지 않는다.
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
});
it('한도를 2회로 올린 퀘스트는 두 번까지 받는다', async () => {
// 한도의 유일한 원본이 퀘스트 문서라는 것을 값으로 보인다.
await db().doc(`quests/${ATTEND_QUEST}`).set({ dailyLimit: 2 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
const second = await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(second.balanceAfter).toBe(20);
expect(await codeOf(() => call(fnQuestAttend, { questId: ATTEND_QUEST })))
.toBe('E-POP-03');
const progress = await db().doc(`users/${UID}/questProgress/${ATTEND_QUEST}`).get();
expect(progress.get('count')).toBe(2);
});
});
describe('만보기 — S-H-WALK (fn-quest-verify-steps · 정책 원장 B-26)', () => {
const walk = (steps: number) => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps });
it('첫 구간에 못 미치면 지급하지 않고 사유를 증거에 남긴다', async () => {
expect(await codeOf(() => walk(800))).toBe('E-QST-01');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(logs.size).toBe(1);
expect(only(logs.docs).get('verified')).toBe(false);
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-01');
expect(only(logs.docs).get('value')).toBe(800);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(0);
});
it('사람이 낼 수 없는 걸음 수는 비정상으로 끊는다', async () => {
expect(await codeOf(() => walk(150_000))).toBe('E-QST-02');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
});
it('구간별 보상이 5/6/7/8 로 정확히 나간다', async () => {
// 1 천보 — 첫 구간.
const first = await walk(1_200);
expect(first.rewarded).toBe(5);
expect(first.clearedTier).toBe(1);
expect(first.nextTierSteps).toBe(5_000);
// 5 천보 — 둘째 구간만 추가로 나간다.
const second = await walk(5_200);
expect(second.rewarded).toBe(6);
expect(second.balanceAfter).toBe(11);
// 1 만보 · 1.5 만보.
expect((await walk(10_100)).rewarded).toBe(7);
const last = await walk(15_500);
expect(last.rewarded).toBe(8);
expect(last.nextTierSteps).toBe(0);
// 원장은 구간마다 한 행이다. 합계 26 POP.
const ledger = await db()
.collection(`users/${UID}/popTransactions`)
.where('reasonCode', '==', 'QUEST_STEPS')
.get();
expect(ledger.docs.map((d) => d.get('amount') as number).sort((a, b) => a - b))
.toEqual([5, 6, 7, 8]);
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(26);
});
it('아랫 구간을 건너뛰고 윗 구간만 지급하지 않는다', async () => {
// 한 번에 1.5 만보를 보고해도 1 천보부터 차례로 네 구간이 나간다.
const result = await walk(16_000);
expect(result.rewarded).toBe(26);
expect(result.clearedTier).toBe(4);
const ledger = await db()
.collection(`users/${UID}/popTransactions`)
.where('reasonCode', '==', 'QUEST_STEPS')
.get();
expect(ledger.size).toBe(4);
// 1 천보 구간(5 POP)이 빠진 채 윗 구간만 나간 경우가 없다.
expect(ledger.docs.some((d) => d.get('amount') === 5)).toBe(true);
});
it('같은 구간을 두 번 받지 못한다', async () => {
await walk(5_200);
expect(await codeOf(() => walk(5_400))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(2);
});
it('진행 카운터가 받은 구간 수와 함께 간다', async () => {
await walk(10_100);
const progress = await db().doc(`users/${UID}/questProgress/${STEPS_QUEST}`).get();
expect(progress.get('questId')).toBe(STEPS_QUEST);
// 세 구간을 받았다. 지급과 같은 트랜잭션에서 쓰므로 지급 수와 어긋나지 않는다.
expect(progress.get('count')).toBe(3);
expect(progress.get('rewardedPop')).toBe(18);
});
it('단말 시각이 서버와 크게 어긋나면 증거로 쓰지 않는다', async () => {
const drifted = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
expect(await codeOf(
() => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 10_400, occurredAt: drifted }),
)).toBe('E-QST-02');
});
it('FPI 집계가 읽는 HEALTH 축 이벤트를 남긴다', async () => {
await call(fnQuestVerifySteps, {
questId: STEPS_QUEST, steps: 5_200, fandomId: FANDOM,
});
// `fn-fpi-aggregate` 가 읽는 것은 `fpiEvents` 다. 이 문서가 없으면 퀘스트를 깨도
// FPI 가 움직이지 않고 sFPI 를 거쳐 행성 성장에 닿는 경로도 열리지 않는다.
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
// 5,200보 = 1 천 · 5 천 두 구간. 구간 1개 = 1 단위이며 보상액(5·6 POP)은 쓰지 않는다(C-34).
expect(events.size).toBe(2);
for (const ev of events.docs) {
expect(ev.get('axis')).toBe('health');
expect(ev.get('value')).toBe(1);
expect(ev.get('verified')).toBe(true);
expect(ev.get('uid')).toBe(UID);
}
});
it('FPI 귀속은 클라이언트 fandomId 가 아니라 서버의 대표 팬덤이다', async () => {
await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 1_200, fandomId: 'someone-else' });
expect((await db().collection('fandoms/someone-else/fpiEvents').get()).size).toBe(0);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1);
});
it('판정에 실패하면 FPI 이벤트도 남기지 않는다', async () => {
expect(await codeOf(
() => call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 800, fandomId: FANDOM }),
)).toBe('E-QST-01');
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
expect(events.size).toBe(0);
});
});
describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리뷰 재검토)', () => {
it('걸음: 없는 퀘스트 · 걸음이 아닌 퀘스트 ID 로는 지급하지 않는다', async () => {
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: 'no-such-walk', steps: 5_200 })))
.toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestVerifySteps, { questId: ATTEND_QUEST, steps: 5_200 })))
.toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('Music Shake: 곡은 퀘스트 카탈로그와 대조하고 하루 한도는 곡이 아니라 퀘스트 단위다', async () => {
await db().doc('quests/music-e2e').set({
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1,
});
const shake = (trackId: string, extra: Record<string, unknown> = {}) => call(fnQuestVerifyMusicShake, {
questId: 'music-e2e', trackId, totalSegments: 10,
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, ...extra,
});
expect(await codeOf(() => shake('t1', { questId: 'no-such-music' }))).toBe('E-QST-01');
expect(await codeOf(() => shake('t2'))).toBe('E-QST-01');
expect(await codeOf(() => shake('t1', {
totalSegments: 1, judgement: { perfect: 1, good: 0, miss: 0, maxCombo: 1 },
}))).toBe('E-QST-02');
// 동시 요청도 하루 1회. 곡 ID 를 바꿔도 다시 열리지 않는다.
await Promise.allSettled([shake('t1'), shake('t1')]);
expect(await codeOf(() => shake('t1'))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
});
/** 오늘의 출석으로 기본 보상 원장 행을 만든다 — 광고 증분의 기준 행이다. */
async function baseReward(attendPop = 10): Promise<string> {
await db().doc('config/policy').set({ attendPop }, { merge: true });
return (await call(fnQuestAttend, { questId: ATTEND_QUEST })).txId as string;
}
const adRows = async () => (await db().collection(`users/${UID}/popTransactions`)
.where('reasonCode', '==', 'AD_REWARD').get()).docs;
it('광고: 노출 ID 를 바꿔 동시에 불러도 일일 한도를 넘지 않고, 같은 노출 재전송은 첫 결과를 돌려준다', async () => {
const baseTxId = await baseReward();
await db().doc('config/policy').set({ adDailyLimit: 1 }, { merge: true });
await Promise.allSettled([
call(fnAdReward, { adImpressionId: `imp-a-${Date.now()}`, baseTxId }),
call(fnAdReward, { adImpressionId: `imp-b-${Date.now()}`, baseTxId }),
]);
const ledger = await adRows();
expect(ledger.length).toBe(1);
await db().doc('config/policy').set({ adDailyLimit: 5 }, { merge: true });
// 성공한 호출의 재전송(응답 유실) — 오류가 아니라 같은 txId 로 성공, 지급은 늘지 않는다.
const used = ledger[0]!.get('refPath') as string;
const again = await call(fnAdReward, { adImpressionId: used.split('/')[1], baseTxId });
expect(again.duplicate).toBe(true);
expect(again.txId).toBe(ledger[0]!.id);
// 앱은 이 값을 보상 화면에 띄운다 — 재전송에서도 원장 행의 실제 지급액이다.
expect(again.rewarded).toBe(ledger[0]!.get('amount'));
expect((await adRows()).length).toBe(1);
});
it('광고: 운영 Quest 는 기본 x2 증분만 기여 POP 으로 지급한다 (B-03)', async () => {
const baseTxId = await baseReward(30);
const reply = await call(fnAdReward, { adImpressionId: 'imp-x2', baseTxId });
expect(reply.rewarded).toBe(30);
const row = only(await adRows());
expect(row.get('popType')).toBe('support');
expect(row.get('amount')).toBe(30);
// 기본 보상은 일반 POP 그대로 — 원장 2건으로 분리된다.
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(30);
expect(wallet.get('supportBalance')).toBe(30);
});
it('광고: 지정 Quest 의 x3 은 기본 보상의 2배를 증분으로 준다 (B-03)', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).update({ adMultiplier: 3 });
const baseTxId = await baseReward(30);
expect((await call(fnAdReward, { adImpressionId: 'imp-x3', baseTxId })).rewarded).toBe(60);
});
it('광고: 사용자 생성 Quest 는 기본 보상과 무관한 고정 10 기여 POP 이다 (C-02 · B-28)', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).update({ authorUid: 'creator-1' });
const baseTxId = await baseReward(30);
expect((await call(fnAdReward, { adImpressionId: 'imp-ugc', baseTxId })).rewarded).toBe(10);
const row = only(await adRows());
expect(row.get('popType')).toBe('support');
});
it('광고: 기본 보상 한 건에 광고 보상은 한 번 · 퀘스트 보상이 아닌 행에는 붙지 않는다', async () => {
const baseTxId = await baseReward();
await call(fnAdReward, { adImpressionId: 'imp-once-1', baseTxId });
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-once-2', baseTxId })))
.toBe('E-POP-03');
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-none', baseTxId: 'no-such-tx' })))
.toBe('E-QST-01');
const adTx = only(await adRows()).id;
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-self', baseTxId: adTx })))
.toBe('E-QST-01');
});
});
describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
const inRange = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 };
const farAway = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.4979, lng: 127.0276 };
it('반경 밖이면 거부하고 거리를 증거에 남긴다', async () => {
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...farAway, proofKind: 'qr', proof: 'tok-far' }),
)).toBe('E-QST-03');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('verified')).toBe(false);
expect(only(logs.docs).get('value')).toBeGreaterThan(100);
});
it('모의 위치는 반경 안이어도 비정상으로 끊는다', async () => {
expect(await codeOf(
() => call(fnQuestVerifyPlace, {
...inRange, mocked: true, proofKind: 'qr', proof: 'tok-mock',
}),
)).toBe('E-QST-02');
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('rejectReason')).toBe('E-QST-02');
const evidence = only(logs.docs).get('evidence') as
{ place: { gps: { mocked: boolean } } };
expect(evidence.place.gps.mocked).toBe(true);
});
it('좌표만으로도 반경 안이면 지급한다 — QR · 사진은 추가 수단(C-03)', async () => {
const result = await call(fnQuestVerifyPlace, inRange);
expect(result.rewarded).toBe(100);
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('type')).toBe('placeGps');
expect(only(logs.docs).get('verified')).toBe(true);
});
it('같은 Quest 는 기본 1회다 — 다른 날에도, 동시 요청에도(C-12)', async () => {
const q = { ...inRange, questId: 'place-quest-once' };
await Promise.all([call(fnQuestVerifyPlace, q), call(fnQuestVerifyPlace, q)]);
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
// 같은 장소의 다른 Quest 는 별개 조건이다.
await call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-other' });
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(2);
});
it('반복형은 직전 유효 인증부터 간격이 지나야 하고 최대 횟수까지다(C-12)', async () => {
const q = { ...inRange, questId: 'place-quest-repeat' };
await call(fnQuestVerifyPlace, q);
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
// 간격이 지난 것처럼 직전 인증 시각을 25시간 앞으로 옮긴다.
const progress = db().doc(`users/${UID}/questProgress/place-quest-repeat`);
await progress.update({ lastVerifiedAt: Timestamp.fromMillis(Date.now() - 25 * 3_600_000) });
await call(fnQuestVerifyPlace, q);
await progress.update({ lastVerifiedAt: Timestamp.fromMillis(Date.now() - 25 * 3_600_000) });
expect(await codeOf(() => call(fnQuestVerifyPlace, q))).toBe('E-POP-03');
expect((await progress.get()).get('count')).toBe(2);
});
it('없는 퀘스트 · 다른 장소의 퀘스트 · 다른 팬덤 퀘스트로는 지급하지 않는다(리뷰 B2 · C-16)', async () => {
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'no-such-quest' })))
.toBe('E-QST-01');
await db().doc('quests/place-elsewhere').set({ type: 'location', placeId: 'other-place' });
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-elsewhere' })))
.toBe('E-QST-03');
await db().doc('quests/place-other-fandom').set({ type: 'location', placeId: PLACE, fandomId: 'another' });
expect(await codeOf(() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-other-fandom' })))
.toBe('E-PERM-01');
// 대표 팬덤 Quest 는 허용한다.
await db().doc('quests/place-my-fandom').set({ type: 'location', placeId: PLACE, fandomId: FANDOM });
await call(fnQuestVerifyPlace, { ...inRange, questId: 'place-my-fandom' });
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
});
it('반경 안 + QR 이면 장소 보상값으로 지급하고 인증 재사용을 막는다', async () => {
const result = await call(
fnQuestVerifyPlace, { ...inRange, proofKind: 'qr', proof: 'tok-ok' },
);
expect(result.rewarded).toBe(100);
expect(result.distanceM).toBeLessThanOrEqual(100);
const logs = await db().collection(`users/${UID}/activityLogs`).get();
expect(only(logs.docs).get('verified')).toBe(true);
// 같은 QR 토큰을 다른 Quest 에 다시 쓰는 시도.
expect(await codeOf(
() => call(fnQuestVerifyPlace, { ...inRange, questId: 'place-quest-reuse', proofKind: 'qr', proof: 'tok-ok' }),
)).toBe('E-POP-03');
});
});
describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Campaign 개설자격 A안)', () => {
const count = () => verifiedQuestCountOf(UID);
it('출석은 한도 2회여도 하루 1회 · 걸음은 구간 수와 무관하게 하루 1회', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).set({ dailyLimit: 2 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
expect(await count()).toBe(1);
await call(fnQuestVerifySteps, { questId: STEPS_QUEST, steps: 16_000 }); // 네 구간
expect(await count()).toBe(2);
});
it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => {
await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 });
const shake = () => call(fnQuestVerifyMusicShake, {
questId: 'music-count', trackId: 't1', totalSegments: 10,
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 },
});
await shake();
await shake();
expect(await count()).toBe(2);
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 });
expect(await count()).toBe(3);
});
it('무효 처리는 원장 행으로 찾아 한 번만 빠진다 · 운영자만', async () => {
const { txId } = await call(fnQuestAttend, { questId: ATTEND_QUEST });
const run = (token: Record<string, unknown>) => fnAdminCompletionVoid.run(
{ data: { uid: UID, txId, reason: '허위 출석' }, auth: { uid: 'ops-1', token } } as never) as Promise<Reply>;
expect(await codeOf(() => run({}))).toBe('E-PERM-01');
expect((await run({ role: 'ops' })).voided).toBe(1);
expect((await run({ role: 'ops' })).voided).toBe(0);
expect(await count()).toBe(0);
});
});
describe('FPI 원천 = 인정 활동 (C-34 · C-35)', () => {
const fpiOf = async () => (await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).docs;
it('출석은 COMMUNITY 축 1 단위이고 보상 750 · 1 POP 이 같은 점수다', async () => {
await db().doc('config/policy').set({ attendPop: 750 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
await db().doc(`quests/${ATTEND_QUEST}`).update({ dailyLimit: 2 });
await db().doc('config/policy').set({ attendPop: 1 }, { merge: true });
await call(fnQuestAttend, { questId: ATTEND_QUEST });
const events = await fpiOf();
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['community', 1], ['community', 1]]);
// 지급 POP 필드가 FPI 원천에 없다(G-05).
expect(events.every((e) => e.get('amount') === undefined && e.get('pop') === undefined)).toBe(true);
// 원장에는 두 보상액이 그대로 남는다 — FPI 와 분리.
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.docs.map((d) => d.get('amount')).sort((a, b) => a - b)).toEqual([1, 750]);
// User EXP 도 금액이 아니라 인정 활동 건수다 — 750 POP 과 1 POP 출석이 각각 100(C-18).
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(200);
});
it('현장 방문은 COMMUNITY 축 1 단위다(장소 보상 100 POP 과 무관)', async () => {
await call(fnQuestVerifyPlace, { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237, proofKind: 'qr', proof: 'tok-fpi' });
const events = await fpiOf();
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['community', 1]]);
});
it('보상이 0 이어도 인정된 기여 활동은 FPI 에 반영된다', async () => {
// Zap 은 POP 을 쓰는 기여다. 지급 보상은 없고, 누른 횟수가 CONTRIBUTION 단위다.
await grantPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed-zap:${UID}` });
const { spendPop } = await import('../src/ledger/ledger.js');
await spendPop({ uid: UID, popType: 'activity', amount: 30, reasonCode: 'ZAP_CONTRIBUTION',
fandomId: FANDOM, count: 3, idempotencyKey: `zap:${UID}:1` });
const events = await fpiOf();
expect(events.map((e) => [e.get('axis'), e.get('value')])).toEqual([['contribution', 3]]);
});
});
describe('POP → 행성 반영 (별첨 1 ② 완료 시나리오 1)', () => {
it('출석으로 받은 POP 을 캠페인에 쓰면 화면이 읽는 필드가 함께 움직인다', async () => {
// 0. 화면이 읽을 자리 — 팬덤·멤버 문서. 성운 생성·행성 탄생 경로는 planet_create.test.ts.
await db().doc(`fandoms/${FANDOM}`).set({
name: '퀘스트 루프 팬덤', fpiScore: 0, memberCount: 1, growthPop: 0,
}, { merge: true });
await db().doc(`fandoms/${FANDOM}/members/${UID}`).set({ uid: UID, fandomId: FANDOM, contributedPop: 0 });
// 다음 단계 문턱은 마스터 데이터다(설계서 §2.3). 값은 발주사 수치표 v1.4
// 「Stage Summary」의 누계이며, 운영에서는 `firebase/seed-demo.py` 가 같은 표를 넣는다.
await db().doc('planetStages/2').set({
stageNo: 2, levelFrom: 21, levelTo: 40, requiredPop: 100_000, requiredSfpi: 15_000,
});
// 1. 활동 보상 — 무료 POP 지급.
await call(fnQuestAttend, { questId: ATTEND_QUEST });
// 후원할 금액을 만든다. 지갑만 올리지 않고 원장을 지난다 — 3자 대조가 깨지면
// 차감이 E-POP-02 로 막힌다.
await grantPop({
// 후원은 기여 POP 만 쓴다(원장 B-33) — 기여 잔액을 만든다.
uid: UID, popType: 'support', amount: 3_000,
reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${UID}`,
});
// 2. 받은 POP 을 캠페인에 쓴다 — 기여는 **쓸 때** 집계된다(설계서 §3.6).
await db().doc('campaigns/campaign-quest-e2e').set({
fandomId: FANDOM, status: 'funding', goalPop: 10_000, raisedPop: 0,
endsAt: Timestamp.fromMillis(Date.now() + 86_400_000),
createdAt: FieldValue.serverTimestamp(),
});
await call(fnCampaignFund, {
campaignId: 'campaign-quest-e2e', amountPop: 3_000,
clientSeq: '1',
});
// 3. HOME·MY 가 읽는 필드가 실제로 움직였는가.
const user = await db().doc(`users/${UID}`).get();
expect(user.get('totalContributedPop')).toBe(3_000);
// EXP 는 원장이 움직일 때 함께 갱신된다 — 출석 보상 10 + 후원 3,000(C-18 단순안).
expect(user.get('tier')).toBeUndefined();
// EXP 는 출석(인정 활동 1건)만 — 후원은 EXP 대상이 아니다(리뷰 B1).
expect(user.get('userExp')).toBe(100);
expect(user.get('userLevel')).toBe(1);
const member = await db().doc(`fandoms/${FANDOM}/members/${UID}`).get();
expect(member.get('contributedPop')).toBe(3_000);
// 보상 10 + 후원 3,000. 관리자 지급은 성장에 포함하지 않는다.
expect((await db().doc(`fandoms/${FANDOM}`).get()).get('growthPop')).toBe(3_010);
expect((await db().collection(`users/${UID}/growthEvents`).get()).size).toBe(2);
// 4. 잔액은 출석 보상 10 만 남는다. 활동 POP 과 기여 집계가 섞이지 않는다.
const wallet = await db().doc(`users/${UID}/wallet/main`).get();
expect(wallet.get('activityBalance')).toBe(10);
// 5. 앱이 이 상태를 읽어 HOME·MY 에 그리는지는 기기에서 잰다 —
// `integration_test/pop_loop_emulator_test.dart` 가 같은 에뮬레이터를 물고
// 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다.
});
});
@@ -0,0 +1,436 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, GeoPoint, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { DEFAULT_POLICY } from '../src/core/config.js';
import { grantPop } from '../src/ledger/ledger.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
import { fnAdReward } from '../src/quest/quest.js';
import { fnQuestVerifyPlace } from '../src/quest/verify.js';
import {
isPrivateAddress, linkPreview, parseHtmlMeta, parsePublicUrl,
} from '../src/quest/link_preview.js';
import {
DEFAULT_QUEST_CATALOG, DRAFT_TTL_MS, createQuest, editQuest, endsAtOf, feeOf, fnQuestEdit,
reviewSubmission, settleQuestPool, submitQuest, sweepDrafts,
} from '../src/quest/ugc.js';
import { LINK_PREVIEW_PER_MINUTE, takeLinkPreviewSlot } from '../src/quest/link_preview.js';
import { spendSplit } from '../src/ledger/ledger.js';
import { txIdOf } from '../src/core/ids.js';
/**
* 사용자 생성 Quest — QA 체크리스트 v1.2 시험 사례 3-16 ~ 3-28(C-01 ~ C-12 · C-16 · C-17 · G-01 · G-03 · G-05).
*/
const CREATOR = 'test-ugc-creator';
const PLAYER = 'test-ugc-player';
const FAN = 'f-ugc-a';
const OTHER = 'f-ugc-b';
const PLACE = 'place-ugc';
const BAD_HOST = 'https://quest-ugc.invalid/page'; // 형식은 유효 · 이름 풀이 실패 → preview_failed
const db = () => getFirestore();
const HOUR = 3_600_000;
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
const e = error as { details?: { code?: string }; code?: string };
return e.details?.code ?? e.code ?? 'unknown';
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
const call = (fn: { run(request: never): unknown }, uid: string, data: Record<string, unknown>) =>
fn.run({ data, auth: { uid, token: {} } } as unknown as never) as Promise<Record<string, unknown>>;
async function user(uid: string, fandom: string, ageHours = 48) {
await db().doc(`users/${uid}`).set({
fandomId: fandom, primaryFandomId: fandom, nickname: uid, status: 'active',
createdAt: Timestamp.fromMillis(Date.now() - ageHours * HOUR),
});
}
async function wallet(uid: string) {
const w = await db().doc(`users/${uid}/wallet/main`).get();
return { general: (w.get('activityBalance') as number) ?? 0, contribution: (w.get('supportBalance') as number) ?? 0 };
}
let seq = 0;
const create = (over: Record<string, unknown> = {}) => createQuest({
uid: CREATOR, clientSeq: `s${++seq}`, activityKey: 'vote', title: '투표 인증', description: '인증샷',
rewardPop: 5, capacity: 10, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤직어워드 투표',
...over,
});
const quest = async (id: string) => (await db().doc(`quests/${id}`).get()).data()!;
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
for (const uid of [CREATOR, PLAYER]) await wipeUser(uid);
for (const c of ['quests', 'questSubmissions', 'posts', 'adRewardLogs', 'questPostUses']) {
await db().recursiveDelete(db().collection(c));
}
await db().recursiveDelete(db().collection(`users/${CREATOR}/rateLimits`));
await db().doc('config/questCatalog').delete();
await user(CREATOR, FAN);
await user(PLAYER, FAN);
// 생성 자격(B-29) — 검증 활동 1회 + 예치 잔액(일반 40 · 기여 100).
await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'ugc:seed:g', growthSource: 'quest_reward' });
await db().doc(`users/${CREATOR}/verifiedCompletions/seed_2026-09-01`).set({ questId: 'seed' });
await grantPop({ uid: CREATOR, popType: 'support', amount: 100, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'ugc:seed:c' });
await db().doc(`places/${PLACE}`).set({ name: '홍대', geo: new GeoPoint(37.55, 126.92), radiusM: 100 });
});
describe('순수 규칙', () => {
const policy = DEFAULT_POLICY;
it('C-06 기간 — 기본 +24h · 생성자 시간대 23:59:59 · 31일째 · 1시간 미만 거부', () => {
const at = new Date('2026-09-28T14:30:00Z'); // KST 23:30
expect(endsAtOf(at, undefined, 540, policy).getTime()).toBe(at.getTime() + 24 * HOUR);
expect(() => endsAtOf(at, '2026-09-28', 540, policy)).toThrow(/최소 1시간/);
expect(endsAtOf(at, '2026-09-29', 540, policy).toISOString()).toBe('2026-09-29T14:59:59.000Z');
expect(endsAtOf(at, '2026-10-27', 540, policy).toISOString()).toBe('2026-10-27T14:59:59.000Z'); // 30일째
expect(() => endsAtOf(at, '2026-10-28', 540, policy)).toThrow(/30일/); // 31일째
expect(() => endsAtOf(at, '2026-09-27', 540, policy)).toThrow();
});
it('B-30 수수료 — Pool 50 → 10 · 500 → 50 · 무보상(Pool 0)은 0(Q-UQ-02 A안) · 설정을 켜면 최소 10', () => {
expect(feeOf(50, policy)).toBe(10);
expect(feeOf(500, policy)).toBe(50);
expect(feeOf(0, policy)).toBe(0);
expect(feeOf(0, { ...policy, ugcFeeOnNoReward: true })).toBe(10);
});
it('C-01 기본 카탈로그 — 4 카테고리 ↔ 10 활동 · ugcAllowed 초기값', () => {
const map = Object.fromEntries(DEFAULT_QUEST_CATALOG.categories.map((c) => [c.key,
DEFAULT_QUEST_CATALOG.activities.filter((a) => a.categoryKey === c.key).map((a) => a.key)]));
expect(map).toEqual({
health: ['walk', 'shake'], planet: ['streaming', 'youtube', 'vote', 'sns'],
community: ['community'], location: ['location', 'qr', 'nfc'],
});
const open = DEFAULT_QUEST_CATALOG.activities.filter((a) => a.ugcAllowed).map((a) => a.key);
expect(open).toEqual(['vote', 'sns', 'community', 'location']);
});
it('C-05 SSRF — http(s) 공개 주소만', async () => {
expect(parsePublicUrl('https://youtu.be/abc')).not.toBeNull();
for (const bad of ['ftp://x.com', 'javascript:alert(1)', 'http://127.0.0.1/', 'http://localhost/',
'http://10.0.0.1', 'http://[::1]/', 'http://u:p@x.com', 'http://x.com:8080/', 'not a url']) {
expect(parsePublicUrl(bad)).toBeNull();
}
for (const ip of ['169.254.169.254', '192.168.1.1', '172.20.0.1', '100.64.0.1', '::ffff:127.0.0.1', 'fd00::1', 'fe80::1']) {
expect(isPrivateAddress(ip)).toBe(true);
}
expect(isPrivateAddress('8.8.8.8')).toBe(false);
expect((await linkPreview('http://169.254.169.254/latest')).status).toBe('invalid');
expect((await linkPreview(BAD_HOST)).status).toBe('preview_failed');
});
it('C-05 메타 파싱은 악성 입력에서도 선형 — ReDoS', () => {
const t0 = performance.now();
parseHtmlMeta('<meta'.repeat(100_000));
parseHtmlMeta(`<meta property="og:title" content="${'a'.repeat(200_000)}`);
parseHtmlMeta(`<title>${'<title>'.repeat(50_000)}`);
expect(performance.now() - t0).toBeLessThan(200);
// </head> 뒤 · 64KB 뒤는 보지 않는다.
expect(parseHtmlMeta('<head></head><meta property="og:title" content="late">').title).toBeUndefined();
});
it('LOW 추가 차단 대역 · 링크 미리보기 분당 상한', async () => {
for (const ip of ['198.51.100.7', '203.0.113.9', '2002:c000:0204::1']) expect(isPrivateAddress(ip)).toBe(true);
for (let i = 0; i < LINK_PREVIEW_PER_MINUTE; i += 1) await takeLinkPreviewSlot(CREATOR);
expect(await codeOf(() => takeLinkPreviewSlot(CREATOR))).toBe('resource-exhausted');
await takeLinkPreviewSlot(CREATOR, Date.now() + 61_000); // 다음 창
});
it('C-05 OG 메타 파싱', () => {
const meta = parseHtmlMeta('<meta property="og:title" content="A &amp; B"><meta content="https://img.x/1.png" property="og:image"><meta property="og:site_name" content="X">');
expect(meta).toEqual({ title: 'A & B', thumbnail: 'https://img.x/1.png', provider: 'X' });
expect(parseHtmlMeta('<title>Only</title><meta property="og:image" content="http://10.0.0.1/a.png">'))
.toEqual({ title: 'Only', thumbnail: undefined, provider: undefined });
});
});
describe('생성', () => {
it('3-20 무효 URL 저장 0건 · 조회 실패는 제목 입력 시 기본 카드로 저장', async () => {
expect(await codeOf(() => create({ url: 'ftp://nope' }))).toBe('invalid-argument');
expect(await codeOf(() => create({ manualTitle: undefined }))).toBe('invalid-argument');
expect((await db().collection('quests').get()).size).toBe(0);
const { questId } = await create();
const q = await quest(questId);
expect(q.status).toBe('published');
expect(q.link).toMatchObject({ status: 'preview_failed', title: '뮤직어워드 투표', url: BAD_HOST });
expect(q.userDescription).toBe('인증샷');
});
it('B-30 · B-06 예치 50 + 수수료 10 — 일반 우선 · 성장 원천 아님', async () => {
const { questId } = await create();
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
const q = await quest(questId);
expect(q.pool).toMatchObject({ deposited: 50, depositSupport: 10, paid: 0, reserved: 0, returned: 0 });
expect(q.feePop).toBe(10);
expect(q.fandomId).toBe(FAN);
const growth = await db().collection(`users/${CREATOR}/growthEvents`).get();
expect(growth.size).toBe(1); // 시드의 quest_reward 1건뿐
});
it('Q-UQ-02 무보상 Quest 는 수수료 없이 공개 · 생성 자격은 보상 없는 Quest 완료도 센다', async () => {
await db().doc('config/questCatalog').set({
...DEFAULT_QUEST_CATALOG,
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'youtube' ? { ...a, ugcAllowed: true } : a)),
});
const make = () => createQuest({
uid: PLAYER, clientSeq: `nr${++seq}`, activityKey: 'youtube', title: '뮤비 시청', description: '',
rewardPop: 0, capacity: 0, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤비',
});
// 보상 원장도 검증 활동도 없으면 자격 미달.
expect(await codeOf(make)).toBe('E-PERM-01');
await db().doc(`users/${PLAYER}/verifiedCompletions/seed_2026-09-01`).set({ questId: 'seed' });
const { questId, status } = await make();
expect(status).toBe('published');
expect(await quest(questId)).toMatchObject({ rewardMode: 'none', feePop: 0, feeTxIds: [] });
expect((await db().collection(`users/${PLAYER}/popTransactions`).get()).size).toBe(0);
});
it('보상 없는 Quest 는 1인 하루 ugcFreeQuestDailyCap(기본 3)개까지 · E-QST-04', async () => {
await db().doc('config/questCatalog').set({
...DEFAULT_QUEST_CATALOG,
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'youtube' ? { ...a, ugcAllowed: true } : a)),
});
const make = () => createQuest({
uid: CREATOR, clientSeq: `cap${++seq}`, activityKey: 'youtube', title: '뮤비', description: '',
rewardPop: 0, capacity: 0, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: '뮤비',
});
for (let i = 0; i < 3; i++) expect((await make()).status).toBe('published');
const err = await make().then(() => null, (e: unknown) => e);
expect((err as { details?: Record<string, unknown> }).details).toEqual({ code: 'E-QST-04', cap: 3 });
// 보상형은 상한과 무관하다.
expect((await create()).status).toBe('published');
});
it('3-16 · 3-18 카탈로그 — ugcAllowed false · 위치 무관 QR 거부 · Admin 변경 즉시 반영', async () => {
expect(await codeOf(() => create({ activityKey: 'walk' }))).toBe('E-PERM-01');
expect(await codeOf(() => create({ activityKey: 'qr' }))).toBe('E-PERM-01');
await db().doc('config/questCatalog').set({
...DEFAULT_QUEST_CATALOG,
activities: DEFAULT_QUEST_CATALOG.activities.map((a) => (a.key === 'vote' ? { ...a, ugcAllowed: false } : a)),
});
expect(await codeOf(() => create())).toBe('E-PERM-01');
});
it('MEDIUM 3 재시도는 Draft 에 굳힌 값으로만 공개한다', async () => {
const seqKey = 'retry-1';
const ref = db().doc(`quests/${txIdOf(`quest:${CREATOR}:${seqKey}`)}`);
// 예치까지 끝나고 공개 전에 끊긴 Draft 를 흉내 낸다.
const parts = await spendSplit({ uid: CREATOR, amount: 50, reasonCode: 'QUEST_POOL_DEPOSIT', idempotencyKey: `questPool:${CREATOR}:${seqKey}` });
await ref.set({
type: 'vote', authorUid: CREATOR, fandomId: FAN, rewardPop: 5, capacity: 10, feePop: 10,
endsAt: Timestamp.fromMillis(Date.now() + 5 * HOUR), status: 'draft', createdAt: Timestamp.now(),
depositTxIds: parts.map((p) => p.txId),
pool: { deposited: 50, depositSupport: 10, paid: 0, reserved: 0, returned: 0, returnedSupport: 0 },
});
const res = await createQuest({
uid: CREATOR, clientSeq: seqKey, activityKey: 'vote', title: '딴 제목', description: '',
rewardPop: 1, capacity: 1, utcOffsetMinutes: 540, url: BAD_HOST, manualTitle: 'x', endDate: '2099-01-01',
});
expect(res.status).toBe('published');
const q = await quest(ref.id);
expect(q).toMatchObject({ status: 'published', rewardPop: 5, capacity: 10, feePop: 10 });
expect((q.endsAt as Timestamp).toMillis()).toBeLessThan(Date.now() + 6 * HOUR);
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
});
it('MEDIUM 4 15분 넘은 Draft 는 예치를 원타입으로 돌려주고 abandoned', async () => {
const ref = db().doc('quests/stale-draft');
const parts = await spendSplit({ uid: CREATOR, amount: 60, reasonCode: 'QUEST_POOL_DEPOSIT', idempotencyKey: 'stale:1' });
await ref.set({
authorUid: CREATOR, status: 'draft', depositTxIds: parts.map((p) => p.txId),
createdAt: Timestamp.fromMillis(Date.now() - DRAFT_TTL_MS - 1_000),
});
await db().doc('quests/fresh-draft').set({ authorUid: CREATOR, status: 'draft', depositTxIds: [], createdAt: Timestamp.now() });
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
expect(await sweepDrafts()).toBe(1);
expect(await quest('stale-draft')).toMatchObject({ status: 'abandoned', poolRefund: 'done' });
expect((await quest('fresh-draft')).status).toBe('draft');
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
expect(await sweepDrafts()).toBe(0); // 두 번 돌려주지 않는다
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
});
it('MEDIUM 7 시간대는 UTC−12 ~ UTC+14', async () => {
expect(await codeOf(() => create({ utcOffsetMinutes: -13 * 60 }))).toBe('invalid-argument');
expect(await codeOf(() => create({ utcOffsetMinutes: 15 * 60 }))).toBe('invalid-argument');
});
it('B-29 가입 24h 미만 · 잔액 부족은 예치도 없다', async () => {
await user(CREATOR, FAN, 2);
expect(await codeOf(() => create())).toBe('E-PERM-01');
await user(CREATOR, FAN);
expect(await codeOf(() => create({ rewardPop: 50, capacity: 10 }))).toBe('E-POP-01');
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
});
it('3-23 공개 후 잠금 필드 거부 · 제목 · 설명 이력', async () => {
const { questId } = await create();
expect(await codeOf(() => call(fnQuestEdit, CREATOR, { questId, rewardPop: 1 }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestEdit, CREATOR, { questId, endsAt: 1 }))).toBe('E-PERM-01');
expect(await codeOf(() => editQuest(PLAYER, questId, { title: 'x' }))).toBe('E-PERM-01');
expect((await editQuest(CREATOR, questId, { title: '투표 인증!', description: '인증샷 1장' })).edited)
.toEqual(['title', 'description']);
const edits = await db().collection(`quests/${questId}/edits`).get();
expect(edits.docs.map((d) => [d.get('field'), d.get('before'), d.get('after')]).sort())
.toEqual([['description', '인증샷', '인증샷 1장'], ['title', '투표 인증', '투표 인증!']]);
expect((await quest(questId)).rewardPop).toBe(5);
});
});
describe('참여 · 검토 · 종료', () => {
it('3-27 대표 팬덤이 아닌 팬덤 Quest 거부 · 본인 Quest 거부', async () => {
const { questId } = await create();
await user(PLAYER, OTHER);
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: 'done' }))).toBe('E-PERM-01');
expect(await codeOf(() => submitQuest(CREATOR, questId, { text: 'done' }))).toBe('E-PERM-01');
});
it('3-28 · 3-17 제출 → 대표 팬덤 변경 → 승인 지급 · 4요소 보존 · 광고 보상은 회사 재원', async () => {
const { questId } = await create();
await submitQuest(PLAYER, questId, { url: 'https://x.com/p/1', text: '투표함' });
expect((await quest(questId)).pool.reserved).toBe(5);
await user(PLAYER, OTHER); // 대표 팬덤 변경(C-17)
const subId = `${questId}_${PLAYER}`;
expect((await db().doc(`questSubmissions/${subId}`).get()).get('status')).toBe('submitted');
expect(await codeOf(() => reviewSubmission({ actorUid: PLAYER, isOps: false, submissionId: subId, action: 'approve' })))
.toBe('E-PERM-01');
const paid = await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'approve' });
expect(paid.status).toBe('paid');
const sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub).toMatchObject({ status: 'paid', payoutStatus: 'succeeded', fandomIdAtSubmit: FAN });
expect(sub.submittedAt).toBeInstanceOf(Timestamp);
expect(sub.evidence).toHaveLength(1);
expect(sub.reviewLog.map((r: { action: string }) => r.action)).toEqual(['submit', 'approve']);
expect((await wallet(PLAYER)).general).toBe(5);
// 운영 검토 통과 = 검증 활동 1회(Campaign 개설자격 A안).
expect(await verifiedQuestCountOf(PLAYER)).toBe(1);
const ad = await call(fnAdReward, PLAYER, { adImpressionId: 'imp-ugc-1', baseTxId: paid.txId });
expect(ad.rewarded).toBe(DEFAULT_POLICY.adRewardPop);
const q = await quest(questId);
expect(q.pool).toMatchObject({ paid: 5, reserved: 0 }); // 광고 보상은 Pool 무관
});
it('3-24 · G-01 반려 — 사유 · 재제출 가능 시 예약 유지 · 보완은 같은 건 · 최종 반려 시 해제', async () => {
const { questId } = await create();
const subId = `${questId}_${PLAYER}`;
await submitQuest(PLAYER, questId, { text: '1차' });
expect(await codeOf(() => reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject' })))
.toBe('invalid-argument');
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '사진 불명확', resubmitAllowed: true });
let sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub).toMatchObject({ status: 'rejected', rejectReason: '사진 불명확', resubmitAllowed: true });
expect((await quest(questId)).pool.reserved).toBe(5);
await submitQuest(PLAYER, questId, { text: '보완' });
sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub.status).toBe('submitted');
expect(sub.evidence).toHaveLength(2);
await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: subId, action: 'reject', reason: '조건 불충족' });
expect((await quest(questId)).pool.reserved).toBe(0);
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: '다시' }))).toBe('E-POP-03');
});
it('3-25 · 3-22 종료 — 서버 시각 마감 · 즉시 반환 · 예약 유지 · 자동 승인 0 · Pool 등식', async () => {
const { questId } = await create();
await submitQuest(PLAYER, questId, { text: '제출' });
const ref = db().doc(`quests/${questId}`);
// 25시간 방치 — 자동 승인 경로가 없으므로 여전히 검토 대기.
await db().doc(`questSubmissions/${questId}_${PLAYER}`).update({ submittedAt: Timestamp.fromMillis(Date.now() - 25 * HOUR) });
await ref.update({ endsAt: Timestamp.fromMillis(Date.now() + 1_000) });
expect(await settleQuestPool(questId)).toBe(0); // 마감 −1초: 아직 열림
await ref.update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
expect(await codeOf(() => submitQuest('someone', questId, { text: 'late' }))).toBe('E-QST-01'); // +1초 거부
expect(await settleQuestPool(questId)).toBe(45);
let q = await quest(questId);
expect(q.status).toBe('ended');
expect(q.pool).toMatchObject({ deposited: 50, paid: 0, reserved: 5, returned: 45, returnedSupport: 10 });
expect((await db().doc(`questSubmissions/${questId}_${PLAYER}`).get()).get('status')).toBe('submitted');
expect(await wallet(CREATOR)).toEqual({ general: 35, contribution: 90 });
expect(await settleQuestPool(questId)).toBe(0); // 재실행 멱등
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: `${questId}_${PLAYER}`, action: 'reject', reason: '불충족' });
q = await quest(questId);
expect(q.pool.paid + q.pool.reserved + q.pool.returned).toBe(q.pool.deposited);
expect(q.pool.returned).toBe(50);
const returns = await db().collection(`users/${CREATOR}/popTransactions`).where('reasonCode', '==', 'QUEST_POOL_RETURN').get();
expect(returns.docs.map((d) => d.get('amount')).reduce((a: number, b: number) => a + b, 0)).toBe(50);
});
it('HIGH 2 마감 뒤 보완 대기 반려 건은 정산이 최종 반려로 닫고 예약을 반환한다', async () => {
const { questId } = await create();
const subId = `${questId}_${PLAYER}`;
await submitQuest(PLAYER, questId, { text: '1차' });
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '보완 필요', resubmitAllowed: true });
// 마감 전에는 검토자가 닫을 수 없다(보완 대기).
expect(await codeOf(() => reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '끝' })))
.toBe('E-POP-03');
await db().doc(`quests/${questId}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
expect(await codeOf(() => submitQuest(PLAYER, questId, { text: '늦은 보완' }))).toBe('E-QST-01');
expect(await settleQuestPool(questId)).toBe(50);
const sub = (await db().doc(`questSubmissions/${subId}`).get()).data()!;
expect(sub).toMatchObject({ status: 'rejected_final', resubmitAllowed: false, payoutStatus: 'released' });
const q = await quest(questId);
expect(q.pool).toMatchObject({ reserved: 0, returned: 50 });
expect(q.pool.paid + q.pool.reserved + q.pool.returned).toBe(q.pool.deposited);
});
it('HIGH 2 마감 뒤 검토자가 보완 대기 건을 직접 최종 반려할 수 있다', async () => {
const { questId } = await create();
const subId = `${questId}_${PLAYER}`;
await submitQuest(PLAYER, questId, { text: '1차' });
await reviewSubmission({ actorUid: CREATOR, isOps: false, submissionId: subId, action: 'reject', reason: '보완', resubmitAllowed: true });
await db().doc(`quests/${questId}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) });
const res = await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: subId, action: 'reject', reason: '기한 경과' });
expect(res.status).toBe('rejected_final');
expect((await quest(questId)).pool).toMatchObject({ reserved: 0, returned: 50 });
});
it('3-26 위치 UGC — Pool 에서 보상액 지급 · 기본 1회', async () => {
const { questId } = await create({ activityKey: 'location', url: undefined, placeId: PLACE });
const verify = () => call(fnQuestVerifyPlace, PLAYER, { questId, placeId: PLACE, lat: 37.55, lng: 126.92 });
expect((await verify()).rewarded).toBe(5);
expect(await codeOf(verify)).toBe('E-POP-03');
expect((await quest(questId)).pool).toMatchObject({ paid: 5 });
expect((await quest(questId)).verifyMethods).toEqual(['gps']);
});
it('C-11 Community — 다른 회원 반응이 달린 응원글이면 즉시 지급 · 본인 반응 제외', async () => {
const { questId } = await create({ activityKey: 'community', url: undefined });
const post = db().doc('posts/ugc-post');
// 공개 전에 쓴 글은 세지 않는다.
await db().doc('posts/old-post').set({
fandomId: FAN, authorUid: PLAYER, visibility: 'visible', likeCount: 5,
createdAt: Timestamp.fromMillis(Date.now() - 2 * HOUR),
});
// likeCount 는 라운지 트리거처럼 본인 반응을 포함해 센다.
await post.set({ fandomId: FAN, authorUid: PLAYER, visibility: 'visible', createdAt: Timestamp.now(), likeCount: 1 });
await post.collection('likes').doc(PLAYER).set({ createdAt: Timestamp.now() });
expect(await codeOf(() => submitQuest(PLAYER, questId, {}))).toBe('E-QST-01');
await post.collection('likes').doc('someone-else').set({ createdAt: Timestamp.now() });
await post.update({ likeCount: 2 });
const res = await submitQuest(PLAYER, questId, {});
expect(res).toMatchObject({ status: 'paid', rewarded: 5 });
expect(await codeOf(() => submitQuest(PLAYER, questId, {}))).toBe('E-POP-03');
expect((await quest(questId)).usedPostIds).toEqual(['ugc-post']);
// 같은 글로 다른 Quest 를 채우지 못한다.
const second = await create({ activityKey: 'community', url: undefined });
expect(await codeOf(() => submitQuest(PLAYER, second.questId, {}))).toBe('E-QST-01');
});
});
@@ -0,0 +1,192 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnInviteCodeGet, fnReferralAttribute, fnReferralRewardOnCompletion, fnReferralSettle,
inviteCodeOf, onReferralCompletionVoided, payReferralReward, settleReferral,
} from '../src/account/referral.js';
/** 추천 — 초대 코드 발급 · 귀속 · 보류 지급 · 24h 정산 · 무효 시 해제/회수(B-69 · B-71). */
const A = 'test-ref-a';
const B = 'test-ref-b';
const C = 'test-ref-c';
const USERS = [A, B, C];
const FANDOM = 'test-ref-fandom';
const db = () => getFirestore();
type AttrReq = Parameters<typeof fnReferralAttribute.run>[0];
const attribute = (uid: string, referralCode: string) =>
fnReferralAttribute.run({ data: { referralCode, deviceRaw: 'device-1' }, auth: { uid, token: {} } } as AttrReq);
const codeOf = async (p: Promise<unknown>) => {
try { await p; return 'ok'; } catch (e) { return (e as { details?: { code?: string } }).details?.code; }
};
const wallet = async (uid: string) => (await db().doc(`users/${uid}/wallet/main`).get()).data() ?? {};
const lock = async (uid: string) => (await db().doc(`referralsByUser/${uid}`).get()).data() ?? {};
const complete = async (uid: string, id = 'q_2026-09-28') => {
await db().doc(`users/${uid}/verifiedCompletions/${id}`).set({ questId: 'q' });
await fnReferralRewardOnCompletion.run({ params: { uid, completionId: id } } as never);
};
const fpiEvents = async () => (await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).docs;
async function cleanup() {
const owned = await db().collection('inviteCodeOwners').where('uid', 'in', USERS).get();
for (const d of owned.docs) await Promise.all([d.ref.delete(), db().doc(`inviteCodes/${d.id}`).delete()]);
for (const u of USERS) {
await db().doc(`referralsByUser/${u}`).delete();
const rows = await db().collection('referrals').where('referredUid', '==', u).get();
await Promise.all(rows.docs.map((d) => d.ref.delete()));
}
const daily = await db().collection('referralDaily').get();
await Promise.all(daily.docs.filter((d) => USERS.some((u) => d.id.startsWith(`${u}_`))).map((d) => d.ref.delete()));
await db().recursiveDelete(db().doc(`fandoms/${FANDOM}`));
await db().doc('config/policy').delete();
for (const u of USERS) await wipeUser(u);
for (const u of USERS) {
await db().doc(`users/${u}`).set({ uid: u, fandomId: FANDOM, createdAt: Timestamp.now() });
}
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await cleanup(); await shutdownAdmin(); });
beforeEach(cleanup);
describe('초대 코드 발급', () => {
it('사용자당 1개 · 10자 32진 · 공개 문서는 {active} 만, 주인은 서버 전용 문서', async () => {
const [x, y] = await Promise.all([inviteCodeOf(A), inviteCodeOf(A)]);
const again = (await fnInviteCodeGet.run({ data: {}, auth: { uid: A, token: {} } } as
Parameters<typeof fnInviteCodeGet.run>[0])).code;
expect(new Set([x, y, again]).size).toBe(1);
expect(x).toMatch(/^[A-HJ-NP-Z2-9]{10}$/);
expect((await db().doc(`inviteCodes/${x}`).get()).data()).toEqual({ active: true });
expect((await db().doc(`inviteCodeOwners/${x}`).get()).get('uid')).toBe(A);
expect(await inviteCodeOf(B)).not.toBe(x);
});
it('형식이 틀린 코드는 조회 전에 거부한다', async () => {
for (const bad of ['short', 'ABCDEFGHI1', 'abcdefgh23', 'ABCDEFGH23/x']) {
expect(await codeOf(attribute(B, bad))).toBe('E-POP-02');
}
// 형식은 맞지만 없는 코드.
expect(await codeOf(attribute(B, 'ZZZZZZZZZ2'))).toBe('E-POP-02');
});
});
describe('귀속 제한', () => {
it('자기 추천 · 서로 추천 · 가입 7일 경과 · 가입자당 1회', async () => {
const codeA = await inviteCodeOf(A);
const codeB = await inviteCodeOf(B);
expect(await codeOf(attribute(A, codeA))).toBe('E-QST-02');
expect((await attribute(B, codeA)).reward).toBe('pending');
expect((await db().doc(`referrals/${B}_${codeA}`).get()).get('deviceHash')).toMatch(/^[0-9a-f]{32}$/);
expect(await codeOf(attribute(A, codeB))).toBe('E-QST-02'); // B→A 가 있으니 A→B 불가
expect(await codeOf(attribute(B, await inviteCodeOf(C)))).toBe('E-POP-03');
await db().doc(`users/${C}`).update({ createdAt: Timestamp.fromMillis(Date.now() - 8 * 86_400_000) });
expect(await codeOf(attribute(C, codeA))).toBe('E-POP-02');
});
it('서로 다른 코드 두 개를 동시에 보내도 귀속은 1건이다', async () => {
const [ca, cb] = [await inviteCodeOf(A), await inviteCodeOf(B)];
const settled = await Promise.allSettled([attribute(C, ca), attribute(C, cb)]);
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect((await db().collection('referrals').where('referredUid', '==', C).get()).size).toBe(1);
});
it('추천인 하루 상한(referralDailyCapPerReferrer)을 넘으면 귀속만 남고 capped', async () => {
await db().doc('config/policy').set({ referralDailyCapPerReferrer: 1 });
const codeA = await inviteCodeOf(A);
expect((await attribute(B, codeA)).reward).toBe('pending');
expect((await attribute(C, codeA)).reward).toBe('capped');
await complete(C);
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
});
it('귀속 전에 검증 활동이 있으면 ineligible — 지급 없음', async () => {
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-27`).set({ questId: 'q' });
expect((await attribute(B, await inviteCodeOf(A))).reward).toBe('ineligible');
await complete(B, 'q_2026-09-28');
expect((await wallet(A)).pendingActivity ?? 0).toBe(0);
});
it('경합 — 잠금보다 먼저 생긴 완료로는 지급하지 않는다', async () => {
await db().doc(`users/${B}/verifiedCompletions/q_early`).set({ questId: 'q' });
// 귀속 트랜잭션이 완료를 못 본 채 잠금을 쓴 상황을 그대로 만든다.
await db().doc(`referrals/${B}_RACE`).set({ referredUid: B, referrerUid: A, reward: 'pending' });
await db().doc(`referralsByUser/${B}`).set({ attributionId: `${B}_RACE`, referrerUid: A, reward: 'pending' });
expect(await payReferralReward(B, 'q_early')).toBe(false);
await db().doc(`users/${B}/verifiedCompletions/q_late`).set({ questId: 'q' });
expect(await payReferralReward(B, 'q_late')).toBe(true);
await db().doc(`referrals/${B}_RACE`).delete();
});
});
describe('보류 지급 → 24h 정산 → 무효 처리', () => {
it('첫 검증 활동은 보류 지급 100 · 20, 정산 뒤 확정 · FPI 1 단위, 재실행해도 한 번', async () => {
await attribute(B, await inviteCodeOf(A));
expect(await wallet(A)).toEqual({});
await complete(B);
await complete(B, 'q_2026-09-29');
expect((await wallet(A)).pendingActivity).toBe(100);
expect((await wallet(B)).pendingActivity).toBe(20);
expect((await wallet(A)).activityBalance).toBe(0);
expect((await lock(B)).reward).toBe('held');
expect(await fpiEvents()).toHaveLength(0);
// 보류 시간이 안 지났으면 정산 작업이 건드리지 않는다.
const run = () => fnReferralSettle.run({ scheduleTime: new Date().toISOString() } as never);
await run();
expect((await lock(B)).reward).toBe('held');
await db().doc(`referralsByUser/${B}`).update({ settleAfter: Timestamp.fromMillis(Date.now() - 1) });
await run();
await run();
expect((await lock(B)).reward).toBe('paid');
expect(await wallet(A)).toMatchObject({ activityBalance: 100, pendingActivity: 0 });
expect(await wallet(B)).toMatchObject({ activityBalance: 20, pendingActivity: 0 });
const events = await fpiEvents();
expect(events.map((e) => [e.get('axis'), e.get('dauEligible')])).toEqual([['referral', false]]);
expect((await db().doc(`users/${A}`).get()).get('userExp')).toBeUndefined();
});
it('정산 전에 유일한 완료가 무효가 되면 두 보류 지급을 해제한다', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B);
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true });
expect(await onReferralCompletionVoided(B)).toBe('released');
expect(await wallet(A)).toMatchObject({ activityBalance: 0, pendingActivity: 0 });
expect(await wallet(B)).toMatchObject({ activityBalance: 0, pendingActivity: 0 });
expect(await settleReferral(B)).toBeNull();
expect(await fpiEvents()).toHaveLength(0);
});
it('다른 유효 완료가 남아 있으면 무효 하나로는 건드리지 않는다', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B, 'q1');
await complete(B, 'q2');
await db().doc(`users/${B}/verifiedCompletions/q1`).update({ voided: true });
expect(await onReferralCompletionVoided(B)).toBeNull();
expect((await lock(B)).reward).toBe('held');
});
it('정산 뒤 무효면 ADMIN_CLAWBACK 으로 회수하고 FPI 이벤트를 집계에서 뺀다(멱등)', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B);
expect(await settleReferral(B)).toBe('paid');
await db().doc(`users/${B}/verifiedCompletions/q_2026-09-28`).update({ voided: true });
expect(await onReferralCompletionVoided(B)).toBe('clawedback');
expect(await onReferralCompletionVoided(B)).toBeNull();
expect((await wallet(A)).activityBalance).toBe(0);
expect((await wallet(B)).activityBalance).toBe(0);
const reasons = (await db().collection(`users/${A}/popTransactions`).get()).docs.map((d) => d.get('reasonCode'));
expect(reasons.filter((r) => r === 'ADMIN_CLAWBACK')).toHaveLength(1);
expect((await fpiEvents()).map((e) => e.get('verified'))).toEqual([false]);
});
it('이상 표시(flagged)가 있으면 정산 때 해제한다', async () => {
await attribute(B, await inviteCodeOf(A));
await complete(B);
await db().doc(`referralsByUser/${B}`).update({ flagged: true });
expect(await settleReferral(B)).toBe('released');
expect((await wallet(A)).activityBalance).toBe(0);
});
});
+26
View File
@@ -23,3 +23,29 @@ export function bootAdmin(): App {
export async function shutdownAdmin(): Promise<void> {
for (const app of getApps()) await deleteApp(app);
}
/**
* 그 사용자가 남긴 문서를 전부 지운다.
*
* **`users/{uid}` 아래만 지우면 모자란다.** 기여 차감은 팬덤 멤버 문서에도
* 누적되므로, 거기를 남겨 두면 다음 테스트에서 값이 겹쳐 올라간다. 실제로
* 그 때문에 「기여 40」을 기대하는 검증이 회차가 쌓이며 120 을 보았다.
*/
export async function wipeUser(uid: string): Promise<void> {
const db = getFirestore();
for (const sub of [
'popLots', 'popTransactions', 'growthEvents', 'questProgress', 'wallet',
'consents', 'purchases', 'subscription', 'subscriptionEvents', 'adBonuses',
'verifiedCompletions', 'rateLimits',
]) {
const snap = await db.collection(`users/${uid}/${sub}`).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
await db.doc(`users/${uid}`).delete();
// 이 사용자가 멤버로 올라간 팬덤 문서까지 지운다.
const members = await db.collectionGroup('members').get();
await Promise.all(
members.docs.filter((d) => d.id === uid).map((d) => d.ref.delete()),
);
}
@@ -0,0 +1,192 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime } from '../src/quest/timeattack.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
/**
* 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06 검증 기준
* 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」.
*/
const UID = 'test-timeattack';
const FANDOM = 'fandom-timeattack';
const db = () => getFirestore();
type Reply = Record<string, unknown>;
const call = async (
fn: { run(request: never): unknown },
data: Record<string, unknown>,
token: Record<string, unknown> = {},
uid = UID,
): Promise<Reply> =>
(await fn.run({ data, auth: { uid, token } } as unknown as never)) as Reply;
async function codeOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (error) {
return (error as { details?: { code?: string } }).details?.code ?? (error as Error).message;
}
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 창을 직접 박은 퀘스트. [fromMs] · [toMs] 는 지금 기준 상대 ms. */
async function quest(id: string, fromMs: number, toMs: number, extra: Reply = {}) {
await db().doc(`quests/${id}`).set({
type: 'timeattack', title: '돌발', fandomId: FANDOM, rewardPop: 30, dailyLimit: 1, active: true,
activeFrom: Timestamp.fromMillis(Date.now() + fromMs),
activeTo: Timestamp.fromMillis(Date.now() + toMs),
...extra,
});
}
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM });
await db().doc(`fandoms/${FANDOM}`).set({ name: '타임어택 행성' }, { merge: true });
await db().recursiveDelete(db().collection(`fandoms/${FANDOM}/fpiEvents`));
});
describe('서버 시각 — fn-server-time', () => {
it('서버 시각(ms)을 돌려준다', async () => {
const before = Date.now();
const reply = await call(fnServerTime, {});
expect(reply.now as number).toBeGreaterThanOrEqual(before);
});
});
describe('타임어택 발동 — fn-quest-timeattack-start', () => {
const start = { fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 };
it('master 가 아니면 거부한다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }, { role: 'ops' })))
.toBe('E-PERM-01');
});
it('창을 서버 시각으로 열고 같은 요청의 재전송은 한 건으로 모은다', async () => {
const before = Date.now();
const first = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
const again = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
expect(again.questId).toBe(first.questId);
expect(again.duplicate).toBe(true);
const doc = await db().doc(`quests/${first.questId as string}`).get();
expect(doc.get('type')).toBe('timeattack');
expect(doc.get('fandomId')).toBe(FANDOM);
const span = (doc.get('activeTo') as Timestamp).toMillis() - (doc.get('activeFrom') as Timestamp).toMillis();
expect(span).toBe(10 * 60_000);
expect((doc.get('activeFrom') as Timestamp).toMillis()).toBeGreaterThanOrEqual(before - 1000);
});
it('보상 상한(config/policy.timeAttackMaxPop) · 경로 문자 · 긴 제목을 거부한다', async () => {
const saved = (await db().doc('config/policy').get()).data();
await db().doc('config/policy').set({ timeAttackMaxPop: 100 });
try {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' });
expect(ok.questId).toBeTruthy();
} finally {
await db().doc('config/policy').delete();
}
try {
// 문서가 없으면 기본 상한 1,000.
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 1001, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01');
} finally {
if (saved) await db().doc('config/policy').set(saved);
}
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, fandomId: 'a/b', requestId: 'r7' }, { role: 'master' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, title: 'x'.repeat(61), requestId: 'r8' }, { role: 'master' }))).toBe('E-QST-01');
});
it('하루를 넘는 창 · 없는 팬덤은 거부한다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 24 * 60 + 1, requestId: 'r2' }, { role: 'master' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, fandomId: 'no-such-fandom', requestId: 'r3' }, { role: 'master' }))).toBe('E-QST-01');
});
it('지속 시간 상한은 config/policy.timeAttackMaxMinutes 를 따른다', async () => {
const saved = (await db().doc('config/policy').get()).data();
await db().doc('config/policy').set({ timeAttackMaxMinutes: 30 });
try {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 31, requestId: 'r9' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 30, requestId: 'r10' }, { role: 'master' });
expect(ok.questId).toBeTruthy();
} finally {
await db().doc('config/policy').delete();
if (saved) await db().doc('config/policy').set(saved);
}
});
});
describe('타임어택 참여 — fn-quest-timeattack', () => {
it('마감 뒤 재시도여도 이미 받은 사용자는 첫 결과를 받는다', async () => {
await quest('ta-retry', -60_000, 60_000);
const first = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
await db().doc('quests/ta-retry').update({ activeTo: Timestamp.fromMillis(Date.now() - 1) });
const again = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
expect(again.txId).toBe(first.txId);
expect(again.duplicate).toBe(true);
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
});
it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위로 남고 재호출은 첫 결과를 돌려준다', async () => {
await quest('ta-open', -60_000, 60_000);
const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(reply.rewarded).toBe(30);
expect(reply.balanceAfter).toBe(30);
const again = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(again).toMatchObject({ txId: reply.txId, balanceAfter: 30, rewarded: 30, duplicate: true });
// 인정 활동 1건 = EXP 100(C-18) · 검증 활동 1회 · FPI COMMUNITY 1 단위(Q-TA-05 권고 A안).
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect(await verifiedQuestCountOf(UID)).toBe(1);
const fpi = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
expect(fpi.docs.map((d) => [d.get('axis'), d.get('units'), d.get('activity')]))
.toEqual([['community', 1, 'timeattack']]);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(ledger.docs[0]!.get('reasonCode')).toBe('QUEST_TIMEATTACK');
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBe(1);
expect((await db().doc(`users/${UID}/questProgress/ta-open`).get()).get('count')).toBe(1);
});
it('timeAttackFpiAxis 가 null 이면 FPI 없이 EXP 만(Q-TA-05 B안)', async () => {
await db().doc('config/policy').set({ timeAttackFpiAxis: null }, { merge: true });
try {
await quest('ta-no-fpi', -60_000, 60_000);
await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' });
} finally {
await db().doc('config/policy').update({ timeAttackFpiAxis: FieldValue.delete() });
}
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(0);
});
it('시작 전 · 마감 후(끝 시각 포함)는 거부한다', async () => {
await quest('ta-early', 60_000, 120_000);
await quest('ta-late', -120_000, -1);
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-early' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-late' }))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('다른 팬덤 · 다른 유형 퀘스트로는 받지 못한다', async () => {
await quest('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' });
await db().doc('quests/ta-attend').set({ type: 'attend', active: true });
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-other' }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-attend' }))).toBe('E-QST-01');
});
});
@@ -0,0 +1,298 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import {
fnTranslationMark, fnTranslationSettle, fnTranslationSubmit,
} from '../src/account/translation.js';
import { grantPop, reconcileUser, settlePendingGrant } from '../src/ledger/ledger.js';
/**
* 참여형 번역 왕복 — 등록(보류 지급) → 마킹(자격 판정) → 24시간 뒤 정산(확정/해제).
* 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한.
*/
const AUTHOR = 'test-trn-author';
const COPIER = 'test-trn-copier';
const MARKER = 'test-trn-marker';
const NEWBIE = 'test-trn-newbie';
const OUTSIDER = 'test-trn-outsider';
const USERS = [AUTHOR, COPIER, MARKER, NEWBIE, OUTSIDER];
const TARGET = 'questions/test-trn-q1';
const POST = 'posts/test-trn-p1';
const CAMPAIGN = 'campaigns/test-trn-c1';
const HIDDEN_POST = 'posts/test-trn-hidden';
const REVIEW_CAMPAIGN = 'campaigns/test-trn-review';
const NO_FANDOM_CAMPAIGN = 'campaigns/test-trn-nofandom';
const DAY = 24 * 3600 * 1000;
type SubmitReq = Parameters<typeof fnTranslationSubmit.run>[0];
type MarkReq = Parameters<typeof fnTranslationMark.run>[0];
const submit = (uid: string, body: string, targetPath = TARGET) => fnTranslationSubmit.run({
data: { targetPath, locale: 'en', body },
auth: { uid, token: {} },
} as SubmitReq);
const mark = (uid: string, entryId: string, valid = true) => fnTranslationMark.run({
data: { entryId, valid },
auth: { uid, token: {} },
} as MarkReq);
const settle = () => fnTranslationSettle.run(
{ scheduleTime: new Date().toISOString() } as Parameters<typeof fnTranslationSettle.run>[0],
);
const age = (entryId: string, hours: number) => getFirestore()
.doc(`translationEntries/${entryId}`)
.update({ createdAt: Timestamp.fromMillis(Date.now() - hours * 3600 * 1000) });
const wallet = async (uid: string) =>
(await getFirestore().doc(`users/${uid}/wallet/main`).get()).data() ?? {};
beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
const db = getFirestore();
await Promise.all(USERS.map(wipeUser));
const old = await db.collection('translationEntries')
.where('targetPath', 'in', [TARGET, POST, `${POST}/comments/c1`, `${POST}/comments/hidden`, CAMPAIGN, NO_FANDOM_CAMPAIGN]).get();
for (const d of old.docs) {
const marks = await d.ref.collection('markings').get();
await Promise.all(marks.docs.map((m) => m.ref.delete()));
await d.ref.delete();
}
const counters = await db.collection('translationDaily').get();
await Promise.all(counters.docs.filter((d) => USERS.includes(d.get('uid'))).map((d) => d.ref.delete()));
const old2 = Timestamp.fromMillis(Date.now() - 2 * DAY);
await Promise.all([
db.doc(TARGET).set({ title: '질문', fandomId: 'trn-f1' }),
db.doc(POST).set({ body: '글', fandomId: 'trn-f1', visibility: 'visible' }),
db.doc(`${POST}/comments/c1`).set({ body: '댓글', visibility: 'visible' }),
db.doc(`${POST}/comments/hidden`).set({ body: '가린 댓글', visibility: 'hidden' }),
db.doc(HIDDEN_POST).set({ body: '가린 글', fandomId: 'trn-f1', visibility: 'hidden' }),
db.doc(CAMPAIGN).set({ title: '캠페인', fandomId: 'trn-f1', status: 'funding' }),
db.doc(REVIEW_CAMPAIGN).set({ title: '검수 중', fandomId: 'trn-f1', status: 'review' }),
db.doc(NO_FANDOM_CAMPAIGN).set({ title: '팬덤 없음', status: 'funding' }),
db.doc(`users/${AUTHOR}`).set({ createdAt: old2, fandomId: 'trn-f1', displayName: '김실명', nickname: '호떡이' }),
db.doc(`users/${COPIER}`).set({ createdAt: old2, fandomId: 'trn-f1' }),
db.doc(`users/${MARKER}`).set({ createdAt: old2, fandomId: 'trn-f1' }),
db.doc(`users/${NEWBIE}`).set({ createdAt: Timestamp.now(), fandomId: 'trn-f1' }),
db.doc(`users/${OUTSIDER}`).set({ createdAt: old2, fandomId: 'trn-f2' }),
]);
});
describe('번역 등록', () => {
it('보상분은 예약 잔액으로 잡히고(G-05) 일일 카운터가 오른다', async () => {
const first = await submit(AUTHOR, 'Hello fandom');
expect(first).toMatchObject({ rewardPop: 5, excludedReason: null });
const w = await wallet(AUTHOR);
expect(w.activityBalance ?? 0).toBe(0);
expect(w.pendingActivity).toBe(5);
const today = (await getFirestore().collection('translationDaily')
.where('uid', '==', AUTHOR).get()).docs[0]!;
expect(today.get('count')).toBe(1);
});
it('같은 대상·언어 재등록은 E-TRN-05 · 남의 문장 복사는 보상 0 · 예약 없음', async () => {
await submit(AUTHOR, 'Hello fandom');
await expect(submit(AUTHOR, 'Hello again')).rejects.toMatchObject({ details: { code: 'E-TRN-05' } });
expect(await submit(COPIER, 'Hello fandom')).toMatchObject({ rewardPop: 0, excludedReason: 'duplicate' });
expect((await wallet(COPIER)).pendingActivity ?? 0).toBe(0);
});
it('번역 댓글은 글 경로(rootPath)와 작성자 표시명을 함께 남긴다 — 댓글 · 캠페인 대상 포함', async () => {
const onComment = await submit(AUTHOR, 'Nice comment', `${POST}/comments/c1`);
const onCampaign = await submit(AUTHOR, 'Cheer up', CAMPAIGN);
const db = getFirestore();
const a = await db.doc(`translationEntries/${onComment.entryId}`).get();
expect(a.get('rootPath')).toBe(POST);
expect(a.get('fandomId')).toBe('trn-f1');
expect(a.get('authorName')).toBe('호떡이');
const b = await db.doc(`translationEntries/${onCampaign.entryId}`).get();
expect(b.get('rootPath')).toBe(CAMPAIGN);
expect(b.get('fandomId')).toBe('trn-f1');
});
it('작성자 닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => {
await getFirestore().doc(`users/${AUTHOR}`).update({ nickname: FieldValue.delete() });
const { entryId } = await submit(AUTHOR, 'Hello fandom');
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(e.get('authorName')).toBe('팬');
expect(e.get('validCount')).toBe(0);
expect(e.get('invalidCount')).toBe(0);
});
it('숨긴 글 · 숨긴 댓글 · 공개 전 캠페인은 없는 콘텐츠와 같은 E-TRN-03 이다', async () => {
for (const target of [HIDDEN_POST, `${POST}/comments/hidden`, REVIEW_CAMPAIGN, `${HIDDEN_POST}/comments/x`]) {
await expect(submit(AUTHOR, 'x', target)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
}
});
it('없는 콘텐츠와 허용 밖 경로는 같은 E-TRN-03 이다', async () => {
await expect(submit(AUTHOR, 'x', 'questions/none-such')).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
await expect(submit(AUTHOR, 'x', `users/${MARKER}`)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
});
it('일일상한 3배(기본 30건)를 넘기면 E-TRN-02', async () => {
const db = getFirestore();
const { dayKey } = await import('../src/core/time.js');
await db.doc(`translationDaily/${AUTHOR}_${dayKey(new Date())}`).set({ uid: AUTHOR, count: 30 });
await expect(submit(AUTHOR, 'Hello')).rejects.toMatchObject({ details: { code: 'E-TRN-02' } });
});
});
describe('번역 마킹', () => {
it('본인 E-TRN-01 · 가입 24시간 미만 · 다른 팬덤(라운지) E-TRN-04 · 두 번째 E-TRN-05', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
await expect(mark(AUTHOR, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-01' } });
await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true });
await expect(mark(MARKER, entryId, false)).rejects.toMatchObject({ details: { code: 'E-TRN-05' } });
// 마킹 수는 같은 트랜잭션에서 번역 문서에 오른다 — 실패한 두 번째 마킹은 세지 않는다.
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(e.get('validCount')).toBe(1);
expect(e.get('invalidCount')).toBe(0);
});
it('무효 마킹은 invalidCount 를 올린다 · 동시에 두 번 눌러도 한 번만 센다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
const results = await Promise.allSettled([mark(MARKER, entryId, false), mark(MARKER, entryId, false)]);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
const e = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(e.get('invalidCount')).toBe(1);
expect(e.get('validCount')).toBe(0);
});
it('등록 뒤 가려진 글의 번역은 마킹하지 못한다(E-TRN-03)', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', `${POST}/comments/c1`);
await getFirestore().doc(POST).update({ visibility: 'hidden' });
await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
});
it('팬덤이 없는 캠페인의 번역은 아무도 마킹하지 못한다(E-TRN-04)', async () => {
const { entryId } = await submit(AUTHOR, 'Cheer', NO_FANDOM_CAMPAIGN);
await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
});
it('캠페인 번역은 그 팬덤 멤버만 마킹한다', async () => {
const { entryId } = await submit(AUTHOR, 'Cheer', CAMPAIGN);
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true });
});
it('INSIDE 번역은 다른 팬덤 회원도 마킹한다(Q-TR-04 권고 B안) · 가입 24시간은 그대로', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true });
});
it('운영값을 끄면(A안) INSIDE 도 그 팬덤 멤버만 마킹한다', async () => {
const policy = getFirestore().doc('config/policy');
const before = await policy.get();
await policy.set({ translationInsideAnyMember: false }, { merge: true });
try {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } });
} finally {
if (before.exists) await policy.set(before.data()!);
else await policy.delete();
}
});
it('팬덤 멤버 문서가 있으면 users.fandomId 가 달라도 마킹할 수 있다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom', POST);
await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).set({ uid: OUTSIDER });
await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true });
await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).delete();
});
});
describe('번역 정산', () => {
it('유효 마킹 1건 이상이면 예약이 사용 가능으로 옮겨지고 대사가 맞는다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await mark(MARKER, entryId);
await age(entryId, 25);
await settle();
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('confirmed');
const w = await wallet(AUTHOR);
expect(w.activityBalance).toBe(5);
expect(w.pendingActivity).toBe(0);
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
});
it('24시간 전이거나 마킹이 모자라면 pending 을 유지한다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await settle();
await age(entryId, 25);
await settle();
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('pending');
expect((await wallet(AUTHOR)).pendingActivity).toBe(5);
});
it('최대 대기(72시간)를 넘기면 예약을 해제하고 excluded 로 닫는다', async () => {
const { entryId } = await submit(AUTHOR, 'Hello fandom');
await age(entryId, 73);
await settle();
const entry = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(entry.get('status')).toBe('excluded');
expect(entry.get('excludedReason')).toBe('unverified');
const w = await wallet(AUTHOR);
expect(w.activityBalance ?? 0).toBe(0);
expect(w.pendingActivity).toBe(0);
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
});
it('보상 0 건은 예약 없이 excluded 로 닫는다', async () => {
await submit(AUTHOR, 'Hello fandom');
const { entryId } = await submit(COPIER, 'Hello fandom');
await age(entryId, 25);
await settle();
expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('excluded');
expect((await wallet(COPIER)).activityBalance ?? 0).toBe(0);
});
});
describe('보류 지급 정산 — settlePendingGrant', () => {
const key = 'trn:ledger-unit';
it('확정은 한 번뿐이고, 확정 뒤 해제 요청은 기존 결과를 돌려준다', async () => {
await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: key, pending: true });
await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'confirm' });
await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'release' });
const w = await wallet(AUTHOR);
expect(w.activityBalance).toBe(7);
expect(w.pendingActivity).toBe(0);
expect((await reconcileUser(AUTHOR)).ok).toBe(true);
});
it('보류가 아닌 지급은 정산할 수 없다', async () => {
await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: `${key}:x` });
await expect(settlePendingGrant({ uid: AUTHOR, idempotencyKey: `${key}:x`, outcome: 'confirm' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
});
});
describe('캠페인 응원 게시판 댓글 번역', () => {
const OPEN = 'campaigns/test-trn-c2';
const DRAFT = 'campaigns/test-trn-c3';
beforeEach(async () => {
const db = getFirestore();
const old = await db.collection('translationEntries')
.where('targetPath', '==', `${OPEN}/comments/ok`).get();
await Promise.all(old.docs.map((d) => d.ref.delete()));
await Promise.all([
db.doc(OPEN).set({ status: 'funding', fandomId: 'trn-f1' }),
db.doc(`${OPEN}/comments/ok`).set({ body: '응원', visibility: 'visible' }),
db.doc(`${OPEN}/comments/hidden`).set({ body: '숨김', visibility: 'hidden' }),
db.doc(DRAFT).set({ status: 'review', fandomId: 'trn-f1' }),
db.doc(`${DRAFT}/comments/ok`).set({ body: '응원', visibility: 'visible' }),
]);
});
it('공개 캠페인의 보이는 댓글에만 달고, 루트는 캠페인이다', async () => {
const { entryId } = await submit(AUTHOR, 'Cheer', `${OPEN}/comments/ok`);
const entry = await getFirestore().doc(`translationEntries/${entryId}`).get();
expect(entry.get('rootPath')).toBe(OPEN);
expect(entry.get('fandomId')).toBe('trn-f1');
for (const path of [`${OPEN}/comments/hidden`, `${DRAFT}/comments/ok`, `${OPEN}/comments/none`]) {
await expect(submit(AUTHOR, 'x', path)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } });
}
});
});