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

593 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, 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, fnQuestVerifySteps } 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, moderateQuest, cancelQuest, fnQuestModerate,
} 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;
/** 공개 요청까지 — 상태 `review`(검수 대기). */
const request = (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 create = async (over: Record<string, unknown> = {}) =>
moderateQuest('ops-1', (await request(over)).questId, 'approve');
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', 'adSsv']) {
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(['walk', '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('review');
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('review');
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: 'shake' }))).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('review');
const q = await quest(ref.id);
expect(q).toMatchObject({ status: 'review', 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);
await db().doc(`adSsv/ssv-ugc-${paid.txId}`).set({ uid: PLAYER, baseTxId: paid.txId, adNetwork: 'admob' });
const ad = await call(fnAdReward, PLAYER, { 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');
});
});
describe('공개 전 검수 · 취소 (상세 8장 · 1주차 4항 · Q8 · B-30)', () => {
it('공개 요청은 검수 대기 — 노출 · 참여 불가, 수수료 · 예치는 차감', async () => {
const { questId, status } = await request();
expect(status).toBe('review');
expect(await quest(questId)).toMatchObject({ status: 'review', active: false });
expect(await wallet(CREATOR)).toEqual({ general: 0, contribution: 80 });
expect(await codeOf(() => submitQuest(PLAYER, questId, { url: 'https://x.com/1' }))).toBe('E-QST-01');
});
it('운영자만 검수한다', async () => {
const { questId } = await request();
const run = (role?: string) => fnQuestModerate.run({
data: { questId, action: 'approve' }, auth: { uid: 'u', token: role ? { role } : {} },
} as never);
expect(await codeOf(() => run())).toBe('E-PERM-01');
expect(await codeOf(() => run('viewer'))).toBe('E-PERM-01');
await run('ops');
expect((await quest(questId)).status).toBe('published');
});
it('승인 — 공개 · 마감은 승인 시각 기준 +24h', async () => {
const { questId } = await request();
const later = new Date(Date.now() + 3 * HOUR);
await moderateQuest('ops-1', questId, 'approve', undefined, later);
const q = await quest(questId);
expect(q).toMatchObject({ status: 'published', active: true });
expect((q.endsAt as Timestamp).toMillis()).toBe(later.getTime() + 24 * HOUR);
expect(await codeOf(() => moderateQuest('ops-1', questId, 'reject', '중복'))).toBe('E-POP-03');
});
it('반려 — 사유 필수 · 예치와 수수료를 원타입으로 모두 반환', async () => {
const { questId } = await request();
expect(await codeOf(() => moderateQuest('ops-1', questId, 'reject', ' '))).toBe('invalid-argument');
await moderateQuest('ops-1', questId, 'reject', '부적절한 링크');
expect(await quest(questId)).toMatchObject({ status: 'rejected', poolRefund: 'done' });
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
const fee = await db().collection(`users/${CREATOR}/popTransactions`).where('reasonCode', '==', 'QUEST_FEE_REFUND').get();
expect(fee.size).toBe(1);
expect(await sweepDrafts()).toBe(0); // 두 번 돌려주지 않는다
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
});
it('검수 중 생성자 취소 — 공개 확정 전이라 전액 반환', async () => {
const { questId } = await request();
expect(await codeOf(() => cancelQuest(PLAYER, questId))).toBe('E-PERM-01');
expect((await cancelQuest(CREATOR, questId)).status).toBe('cancelled');
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 100 });
});
it('공개 후 생성자 취소 — 수수료 비환불 · 미배정 Pool 즉시 반환 · 참여 마감', async () => {
const { questId } = await create();
expect((await cancelQuest(CREATOR, questId)).status).toBe('ended');
expect((await quest(questId)).status).toBe('ended');
// 예치 50(일반 40 · 기여 10)은 원타입으로 돌아오고, 수수료 10(기여)은 Burn 그대로.
expect(await wallet(CREATOR)).toEqual({ general: 40, contribution: 90 });
expect(await codeOf(() => submitQuest(PLAYER, questId, { url: 'https://x.com/1' }))).toBe('E-QST-01');
expect(await codeOf(() => cancelQuest(CREATOR, questId))).toBe('E-POP-03');
});
});
describe('사용자 생성 만보기 (Figma 259:45798 · Q8)', () => {
const walk = (over: Record<string, unknown> = {}) =>
create({ activityKey: 'walk', url: undefined, manualTitle: undefined, goal: 10_000, ...over });
const steps = (questId: string, n: number) => call(fnQuestVerifySteps, PLAYER, { questId, steps: n });
it('목표 걸음을 받아 저장한다 · 목표 없음 · 10만 보 초과는 거부', async () => {
expect(await codeOf(() => walk({ goal: 0 }))).toBe('invalid-argument');
expect(await codeOf(() => walk({ goal: 100_001 }))).toBe('invalid-argument');
const { questId } = await walk();
expect(await quest(questId)).toMatchObject({ type: 'walk', goal: 10_000, status: 'published' });
});
it('목표 미달은 거부 · 달성하면 Pool 에서 1인 1회 즉시 지급 · HEALTH FPI', async () => {
const { questId } = await walk();
expect(await codeOf(() => steps(questId, 9_999))).toBe('E-QST-01');
const paid = await steps(questId, 10_200) as Record<string, unknown>;
expect(paid).toMatchObject({ status: 'paid', rewarded: 5 });
expect((await quest(questId)).pool).toMatchObject({ paid: 5 });
expect(await codeOf(() => steps(questId, 12_000))).toBe('E-POP-03');
const sub = await db().doc(`questSubmissions/${questId}_${PLAYER}`).get();
expect(sub.get('status')).toBe('paid');
const fpi = await db().collection(`fandoms/${FAN}/fpiEvents`).where('sourceId', '==', `ugc:${questId}`).get();
expect(fpi.docs.map((d) => [d.get('uid'), d.get('axis')])).toEqual([[PLAYER, 'health']]);
// 생성자는 자기 Quest 에 참여하지 못한다.
expect(await codeOf(() => call(fnQuestVerifySteps, CREATOR, { questId, steps: 10_200 }))).toBe('E-PERM-01');
});
});
describe('위치 선택 지도 핀 (Figma 371:356705)', () => {
it('핀 좌표 · 주소로 이 Quest 의 장소를 만들고, 그 자리 반경 안에서 인증된다', async () => {
const { questId } = await create({
activityKey: 'location', url: undefined, manualTitle: undefined,
pin: { lat: 37.4979, lng: 127.0276, address: '서울특별시 강남구 역삼동' },
});
const q = await quest(questId);
const place = (await db().doc(`places/${q.placeId}`).get()).data()!;
expect(place).toMatchObject({ name: '서울특별시 강남구 역삼동', source: 'user', createdBy: CREATOR });
const ok = await call(fnQuestVerifyPlace, PLAYER, { questId, placeId: q.placeId, lat: 37.4980, lng: 127.0277 });
expect((ok as Record<string, unknown>).rewarded).toBe(5);
});
it('추가 인증 수단(사진 · QR)을 고르면 GPS 에 덧붙고, QR 이면 생성자만 읽는 코드를 발급한다(C-03)', async () => {
const { questId } = await create({
activityKey: 'location', url: undefined, manualTitle: undefined,
pin: { lat: 37.4979, lng: 127.0276, address: '서울특별시 강남구 역삼동' },
extraVerify: ['qr', 'nfc', 'photo'],
});
expect((await quest(questId)).verifyMethods).toEqual(['gps', 'photo', 'qr']);
const secret = (await db().doc(`questSecrets/${questId}`).get()).data()!;
expect(secret.authorUid).toBe(CREATOR);
expect(secret.qrCode).toMatch(/^[0-9a-f]{24}$/);
});
it('좌표가 틀리면 거부한다', async () => {
expect(await codeOf(() => create({
activityKey: 'location', url: undefined, manualTitle: undefined,
pin: { lat: 91, lng: 0, address: 'x' },
}))).toBe('invalid-argument');
});
});
describe('운영 Vote · SNS · Community Quest 지급 (B-11 · B-13)', () => {
it('운영 Vote — 제출 → 운영 승인 시 FANUZU 재원으로 지급 · 승인 지급도 FPI 원천 1건', async () => {
await db().doc('quests/op-vote').set({ type: 'vote', title: '운영 투표', fandomId: FAN, rewardPop: 7, status: 'published' });
const sub = await submitQuest(PLAYER, 'op-vote', { url: 'https://x.com/vote' });
expect(sub.status).toBe('submitted');
expect(await codeOf(() => reviewSubmission({
actorUid: CREATOR, isOps: false, submissionId: sub.submissionId as string, action: 'approve',
}))).toBe('E-PERM-01');
const paid = await reviewSubmission({ actorUid: 'ops-1', isOps: true, submissionId: sub.submissionId as string, action: 'approve' });
expect(paid.status).toBe('paid');
// 앱이 승인 시점 광고 추가 보상을 붙이는 기준(Q-UQ-03 A).
expect((await db().doc(`questSubmissions/${sub.submissionId as string}`).get()).get('txId')).toBe(paid.txId);
expect(await wallet(PLAYER)).toMatchObject({ general: 7 });
const fpi = await db().collection(`fandoms/${FAN}/fpiEvents`).where('sourceId', '==', 'ugc:op-vote').get();
expect(fpi.docs.map((d) => [d.get('axis'), d.get('activity')])).toEqual([['community', 'reviewed_quest']]);
expect((await quest('op-vote')).pool).toBeUndefined();
});
it('운영 Community — 반응 달린 응원글이면 즉시 지급(Pool 없음)', async () => {
await db().doc('quests/op-com').set({
type: 'community', title: '좋아요', fandomId: FAN, rewardPop: 4, status: 'published',
publishedAt: Timestamp.fromMillis(Date.now() - 60_000),
});
await db().doc('posts/op-post').set({ fandomId: FAN, authorUid: PLAYER, visibility: 'visible', createdAt: Timestamp.now(), likeCount: 1 });
const reply = await submitQuest(PLAYER, 'op-com', {});
expect(reply).toMatchObject({ status: 'paid', rewarded: 4 });
expect((await quest('op-com')).participantCount).toBe(1);
});
});