광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원
This commit is contained in:
@@ -5,14 +5,14 @@ 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 { 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,
|
||||
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';
|
||||
@@ -58,11 +58,15 @@ async function wallet(uid: string) {
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
const create = (over: Record<string, unknown> = {}) => createQuest({
|
||||
/** 공개 요청까지 — 상태 `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()!;
|
||||
|
||||
@@ -70,7 +74,7 @@ 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']) {
|
||||
for (const c of ['quests', 'questSubmissions', 'posts', 'adRewardLogs', 'questPostUses', 'adSsv']) {
|
||||
await db().recursiveDelete(db().collection(c));
|
||||
}
|
||||
await db().recursiveDelete(db().collection(`users/${CREATOR}/rateLimits`));
|
||||
@@ -111,7 +115,7 @@ describe('순수 규칙', () => {
|
||||
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']);
|
||||
expect(open).toEqual(['walk', 'vote', 'sns', 'community', 'location']);
|
||||
});
|
||||
|
||||
it('C-05 SSRF — http(s) 공개 주소만', async () => {
|
||||
@@ -190,7 +194,7 @@ describe('생성', () => {
|
||||
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(status).toBe('review');
|
||||
expect(await quest(questId)).toMatchObject({ rewardMode: 'none', feePop: 0, feeTxIds: [] });
|
||||
expect((await db().collection(`users/${PLAYER}/popTransactions`).get()).size).toBe(0);
|
||||
});
|
||||
@@ -204,7 +208,7 @@ describe('생성', () => {
|
||||
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');
|
||||
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 });
|
||||
// 보상형은 상한과 무관하다.
|
||||
@@ -212,7 +216,7 @@ describe('생성', () => {
|
||||
});
|
||||
|
||||
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: 'shake' }))).toBe('E-PERM-01');
|
||||
expect(await codeOf(() => create({ activityKey: 'qr' }))).toBe('E-PERM-01');
|
||||
await db().doc('config/questCatalog').set({
|
||||
...DEFAULT_QUEST_CATALOG,
|
||||
@@ -236,9 +240,9 @@ describe('생성', () => {
|
||||
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');
|
||||
expect(res.status).toBe('review');
|
||||
const q = await quest(ref.id);
|
||||
expect(q).toMatchObject({ status: 'published', rewardPop: 5, capacity: 10, feePop: 10 });
|
||||
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 });
|
||||
});
|
||||
@@ -316,7 +320,8 @@ describe('참여 · 검토 · 종료', () => {
|
||||
// 운영 검토 통과 = 검증 활동 1회(Campaign 개설자격 A안).
|
||||
expect(await verifiedQuestCountOf(PLAYER)).toBe(1);
|
||||
|
||||
const ad = await call(fnAdReward, PLAYER, { adImpressionId: 'imp-ugc-1', baseTxId: paid.txId });
|
||||
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 무관
|
||||
@@ -434,3 +439,142 @@ describe('참여 · 검토 · 종료', () => {
|
||||
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('좌표가 틀리면 거부한다', 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user