Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
@@ -0,0 +1,436 @@
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');
});
});