595 lines
34 KiB
TypeScript
595 lines
34 KiB
TypeScript
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',
|
||
// 위치 Quest 인증은 위치기반 서비스 동의가 있어야 한다(Q-E1 · R02).
|
||
consentState: { location: { granted: true, version: 'v1' } },
|
||
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 & 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);
|
||
});
|
||
});
|