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): Promise { 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; /** * 배포하지 않고 핸들러를 그대로 실행한다(`CallableFunction.run`). * * 배포·HTTP 왕복 없이도 **판정 로직 전체**가 도는 경로다. 인증 문맥만 흉내 내고 * 나머지는 실제 코드가 그대로 돈다 — 에뮬레이터의 Firestore 를 쓰므로 트랜잭션과 * 문서 ID 멱등까지 실제 동작이다. */ async function call( fn: { run(request: never): unknown }, data: Record = {}, ): Promise { const request = { data, auth: { uid: UID, token: {} } } as unknown as never; return (await fn.run(request)) as Reply; } /** 문서 하나를 꺼낸다. 없으면 그 자리에서 끊는다 — 뒤에서 undefined 로 새지 않게. */ function only(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 = {}) => 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 { 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) => fnAdminCompletionVoid.run( { data: { uid: UID, txId, reason: '허위 출석' }, auth: { uid: 'ops-1', token } } as never) as Promise; 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` 가 같은 에뮬레이터를 물고 // 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다. }); });