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

659 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 보상은 퀘스트의 rewardPop × 등급 배수다', async () => {
await db().doc('quests/music-reward').set({
type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1, rewardPop: 30,
});
const reply = await call(fnQuestVerifyMusicShake, {
questId: 'music-reward', trackId: 't1', totalSegments: 10,
judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 },
});
expect(reply.grade).toBe('S');
expect(reply.rewarded).toBe(60);
});
it('Music Shake — 곡은 지정 영상 링크로 대조하고 · rewardPop 이 없으면 운영값 musicShake.pop · 판정 미달은 보상 0', async () => {
await db().doc('quests/music-link').set({
type: 'musicShake', totalSegments: 10, dailyLimit: 2,
link: { url: 'https://youtu.be/WMweEpGlu_U', title: 'BTS - Butter' },
});
await db().doc('config/policy').set({ musicShake: { pop: 4 } }, { merge: true });
const shake = (perfect: number, miss: number) => call(fnQuestVerifyMusicShake, {
questId: 'music-link', trackId: 'WMweEpGlu_U', totalSegments: 10,
judgement: { perfect, good: 0, miss, maxCombo: perfect },
});
// 3/10 — 임시 성공 문턱 0.5 미달 → 보상 0, 원장 없음.
expect(await codeOf(() => shake(3, 7))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
// 5/10 — 문턱 0.5 충족 · perfect 0.5 < B 문턱 0.6 → C(배수 1) → 4.
const reply = await shake(5, 5);
expect(reply.rewarded).toBe(4);
});
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` 가 같은 에뮬레이터를 물고
// 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다.
});
});