629 lines
32 KiB
TypeScript
629 lines
32 KiB
TypeScript
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` 가 같은 에뮬레이터를 물고
|
|
// 여기서 남긴 문서를 그대로 읽는다. 그래서 이 테스트는 상태를 지우지 않는다.
|
|
});
|
|
});
|