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): Promise { 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) => fn.run({ data, auth: { uid, token: {} } } as unknown as never) as Promise>; 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 = {}) => 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 = {}) => 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('').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(''); expect(meta).toEqual({ title: 'A & B', thumbnail: 'https://img.x/1.png', provider: 'X' }); expect(parseHtmlMeta('Only')) .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 }).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 = {}) => 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; 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).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); }); });