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

437 lines
25 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 } 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,
} 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;
const create = (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 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']) {
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(['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('published');
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('published');
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: 'walk' }))).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('published');
const q = await quest(ref.id);
expect(q).toMatchObject({ status: 'published', 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);
const ad = await call(fnAdReward, PLAYER, { adImpressionId: 'imp-ugc-1', 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');
});
});