광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:09 +09:00
parent 4bb92c2ee7
commit fbf9a2c3d7
39 changed files with 1871 additions and 477 deletions
@@ -0,0 +1,27 @@
import { createSign, generateKeyPairSync } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { verifySsv } from '../src/quest/ad_ssv.js';
/** AdMob SSV 서명 검증(fn-ad-ssv) — 에뮬레이터 없이 도는 순수 검증. */
describe('AdMob SSV 서명 검증', () => {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const keys = new Map([['42', publicKey.export({ type: 'spki', format: 'pem' }) as string]]);
const sign = (message: string) => createSign('sha256').update(message).sign(privateKey)
.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const message = 'ad_network=5450213213286189855&ad_unit=1234&custom_data=tx1&reward_amount=1'
+ '&reward_item=Reward&timestamp=1507770365237823&transaction_id=abc&user_id=u1';
it('서명이 맞으면 파라미터를 돌려준다', () => {
const params = verifySsv(`${message}&signature=${sign(message)}&key_id=42`, keys);
expect(params?.get('user_id')).toBe('u1');
expect(params?.get('custom_data')).toBe('tx1');
});
it('값을 바꾸거나 모르는 키 · 서명 없음이면 거부한다', () => {
const sig = sign(message);
expect(verifySsv(`${message.replace('u1', 'u2')}&signature=${sig}&key_id=42`, keys)).toBeNull();
expect(verifySsv(`${message}&signature=${sig}&key_id=7`, keys)).toBeNull();
expect(verifySsv(message, keys)).toBeNull();
});
});
+48 -23
View File
@@ -81,8 +81,10 @@ beforeEach(async () => {
const snap = await db().collection(`users/${UID}/${sub}`).get();
await Promise.all(snap.docs.map((d) => d.ref.delete()));
}
const proofs = await db().collection('placeProofs').get();
await Promise.all(proofs.docs.map((d) => d.ref.delete()));
for (const c of ['placeProofs', 'adSsv', 'adRewardLogs']) {
const docs = await db().collection(c).get();
await Promise.all(docs.docs.map((d) => d.ref.delete()));
}
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
await Promise.all(events.docs.map((d) => d.ref.delete()));
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 });
@@ -345,29 +347,52 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
const adRows = async () => (await db().collection(`users/${UID}/popTransactions`)
.where('reasonCode', '==', 'AD_REWARD').get()).docs;
it('광고: 노출 ID 를 바꿔 동시에 불러도 일일 한도를 넘지 않고, 같은 노출 재전송은 첫 결과를 돌려준다', async () => {
/** Google 이 보낸 SSV 콜백(서명 검증 통과)을 흉내 낸다 — fn-ad-ssv 가 남기는 기록과 같은 모양. */
async function ssv(baseTxId: string, transactionId = `ssv-${baseTxId}-${Math.random()}`) {
await db().doc(`adSsv/${transactionId}`).set({ uid: UID, baseTxId, adNetwork: 'admob' });
return transactionId;
}
const ad = (baseTxId: string) => call(fnAdReward, { baseTxId });
it('광고: SSV 기록 없이 함수만 부르면 증분을 주지 않고 횟수도 차감하지 않는다 (B-03 · D-26)', async () => {
const baseTxId = await baseReward();
await db().doc('config/policy').set({ adDailyLimit: 1 }, { merge: true });
await Promise.allSettled([
call(fnAdReward, { adImpressionId: `imp-a-${Date.now()}`, baseTxId }),
call(fnAdReward, { adImpressionId: `imp-b-${Date.now()}`, baseTxId }),
]);
expect(await codeOf(() => ad(baseTxId))).toBe('E-AD-03');
// 다른 사람의 SSV 기록은 증거가 아니다.
await db().doc('adSsv/ssv-other').set({ uid: 'someone-else', baseTxId, adNetwork: 'admob' });
expect(await codeOf(() => ad(baseTxId))).toBe('E-AD-03');
expect((await adRows()).length).toBe(0);
expect((await db().doc(`users/${UID}/questProgress/ad`).get()).exists).toBe(false);
await ssv(baseTxId);
expect((await ad(baseTxId)).rewarded).toBe(10);
});
it('광고: 동시에 불러도 한 번만 지급하고, 재전송은 첫 결과를 돌려준다', async () => {
const baseTxId = await baseReward();
await ssv(baseTxId, 'ssv-a');
await ssv(baseTxId, 'ssv-b');
await Promise.allSettled([ad(baseTxId), ad(baseTxId)]);
const ledger = await adRows();
expect(ledger.length).toBe(1);
await db().doc('config/policy').set({ adDailyLimit: 5 }, { merge: true });
// 성공한 호출의 재전송(응답 유실) — 오류가 아니라 같은 txId 로 성공, 지급은 늘지 않는다.
const used = ledger[0]!.get('refPath') as string;
const again = await call(fnAdReward, { adImpressionId: used.split('/')[1], baseTxId });
const again = await ad(baseTxId);
expect(again.duplicate).toBe(true);
expect(again.txId).toBe(ledger[0]!.id);
// 앱은 이 값을 보상 화면에 띄운다 — 재전송에서도 원장 행의 실제 지급액이다.
expect(again.rewarded).toBe(ledger[0]!.get('amount'));
expect((await adRows()).length).toBe(1);
});
it('광고: 일일 한도를 넘으면 E-AD-02', async () => {
await db().doc('config/policy').set({ adDailyLimit: 0 }, { merge: true });
const baseTxId = await baseReward();
await ssv(baseTxId);
expect(await codeOf(() => ad(baseTxId))).toBe('E-AD-02');
await db().doc('config/policy').set({ adDailyLimit: 5 }, { merge: true });
});
it('광고: 운영 Quest 는 기본 x2 증분만 기여 POP 으로 지급한다 (B-03)', async () => {
const baseTxId = await baseReward(30);
const reply = await call(fnAdReward, { adImpressionId: 'imp-x2', baseTxId });
await ssv(baseTxId);
const reply = await ad(baseTxId);
expect(reply.rewarded).toBe(30);
const row = only(await adRows());
expect(row.get('popType')).toBe('support');
@@ -381,27 +406,27 @@ describe('클라이언트가 고른 ID 로 한도를 다시 열 수 없다 (리
it('광고: 지정 Quest 의 x3 은 기본 보상의 2배를 증분으로 준다 (B-03)', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).update({ adMultiplier: 3 });
const baseTxId = await baseReward(30);
expect((await call(fnAdReward, { adImpressionId: 'imp-x3', baseTxId })).rewarded).toBe(60);
await ssv(baseTxId);
expect((await ad(baseTxId)).rewarded).toBe(60);
});
it('광고: 사용자 생성 Quest 는 기본 보상과 무관한 고정 10 기여 POP 이다 (C-02 · B-28)', async () => {
await db().doc(`quests/${ATTEND_QUEST}`).update({ authorUid: 'creator-1' });
const baseTxId = await baseReward(30);
expect((await call(fnAdReward, { adImpressionId: 'imp-ugc', baseTxId })).rewarded).toBe(10);
await ssv(baseTxId);
expect((await ad(baseTxId)).rewarded).toBe(10);
const row = only(await adRows());
expect(row.get('popType')).toBe('support');
});
it('광고: 기본 보상 한 건에 광고 보상은 한 번 · 퀘스트 보상이 아닌 행에는 붙지 않는다', async () => {
it('광고: 퀘스트 보상이 아닌 행에는 붙지 않는다', async () => {
expect(await codeOf(() => ad('no-such-tx'))).toBe('E-QST-01');
const baseTxId = await baseReward();
await call(fnAdReward, { adImpressionId: 'imp-once-1', baseTxId });
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-once-2', baseTxId })))
.toBe('E-POP-03');
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-none', baseTxId: 'no-such-tx' })))
.toBe('E-QST-01');
await ssv(baseTxId);
await ad(baseTxId);
const adTx = only(await adRows()).id;
expect(await codeOf(() => call(fnAdReward, { adImpressionId: 'imp-self', baseTxId: adTx })))
.toBe('E-QST-01');
await ssv(adTx);
expect(await codeOf(() => ad(adTx))).toBe('E-QST-01');
});
});
+155 -11
View File
@@ -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);
});
});
+97 -87
View File
@@ -2,16 +2,20 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore';
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
import { fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime } from '../src/quest/timeattack.js';
import {
fnQuestTimeAttack, fnQuestTimeAttackStart, fnServerTime, joinTimeAttack, notifyDueRounds,
} from '../src/quest/timeattack.js';
import { participateStreaming } from '../src/quest/quest.js';
import { verifiedQuestCountOf } from '../src/ledger/fpi.js';
/**
* 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06 검증 기준
* TIME ATTACK — 10/3 통합회신 3절(기존 Quest 에 붙는 제한시간 모드 · Q-TA-01~05) · 별첨 1 ③-4
* 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」.
*/
const UID = 'test-timeattack';
const FANDOM = 'fandom-timeattack';
const BASE = 'ta-base-stream';
const db = () => getFirestore();
type Reply = Record<string, unknown>;
@@ -32,12 +36,16 @@ async function codeOf(run: () => Promise<unknown>): Promise<string> {
throw new Error('실패해야 하는 호출이 성공했습니다.');
}
/** 창을 직접 박은 퀘스트. [fromMs] · [toMs] 는 지금 기준 상대 ms. */
async function quest(id: string, fromMs: number, toMs: number, extra: Reply = {}) {
/** 회차를 직접 박은 원 Quest. [fromMs] · [toMs] 는 지금 기준 상대 ms. */
async function round(id: string, fromMs: number, toMs: number, extra: Reply = {}, type = 'streaming') {
await db().doc(`quests/${id}`).set({
type: 'timeattack', title: '돌발', fandomId: FANDOM, rewardPop: 30, dailyLimit: 1, active: true,
activeFrom: Timestamp.fromMillis(Date.now() + fromMs),
activeTo: Timestamp.fromMillis(Date.now() + toMs),
type, title: '총공', fandomId: FANDOM, dailyLimit: 1, active: true,
timeAttack: {
roundId: `r-${id}`, title: '돌발', fandomId: FANDOM, rewardPop: 30, scheduled: false,
activeFrom: Timestamp.fromMillis(Date.now() + fromMs),
activeTo: Timestamp.fromMillis(Date.now() + toMs),
notifyAt: null, preNotified: true,
},
...extra,
});
}
@@ -46,9 +54,12 @@ beforeAll(() => { bootAdmin(); });
afterAll(async () => { await shutdownAdmin(); });
beforeEach(async () => {
await wipeUser(UID);
await db().recursiveDelete(db().collection('quests'));
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM });
await db().doc(`fandoms/${FANDOM}`).set({ name: '타임어택 행성' }, { merge: true });
await db().recursiveDelete(db().collection(`fandoms/${FANDOM}/fpiEvents`));
await db().doc(`quests/${BASE}`).set({ type: 'streaming', title: '스트리밍 총공', fandomId: FANDOM, dailyLimit: 1 });
await db().doc('config/policy').delete();
});
describe('서버 시각 — fn-server-time', () => {
@@ -59,8 +70,8 @@ describe('서버 시각 — fn-server-time', () => {
});
});
describe('타임어택 발동 — fn-quest-timeattack-start', () => {
const start = { fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 };
describe('회차 발동 — fn-quest-timeattack-start', () => {
const start = { questId: BASE, fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 };
it('master 가 아니면 거부한다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }))).toBe('E-PERM-01');
@@ -68,125 +79,124 @@ describe('타임어택 발동 — fn-quest-timeattack-start', () => {
.toBe('E-PERM-01');
});
it('창을 서버 시각으로 열고 같은 요청의 재전송은 한 건으로 모은다', async () => {
it('별도 유형을 만들지 않고 원 Quest 에 즉시 회차를 붙인다 · 재전송은 한 건', async () => {
const before = Date.now();
const first = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
const again = await call(fnQuestTimeAttackStart, { ...start, requestId: 'r1' }, { role: 'master' });
expect(again.questId).toBe(first.questId);
expect(again.roundId).toBe(first.roundId);
expect(again.duplicate).toBe(true);
const doc = await db().doc(`quests/${first.questId as string}`).get();
expect(doc.get('type')).toBe('timeattack');
expect(doc.get('fandomId')).toBe(FANDOM);
const span = (doc.get('activeTo') as Timestamp).toMillis() - (doc.get('activeFrom') as Timestamp).toMillis();
expect(span).toBe(10 * 60_000);
expect((doc.get('activeFrom') as Timestamp).toMillis()).toBeGreaterThanOrEqual(before - 1000);
expect((await db().collection('quests').get()).size).toBe(1);
const doc = await db().doc(`quests/${BASE}`).get();
expect(doc.get('type')).toBe('streaming');
const r = doc.get('timeAttack');
expect(r.activeTo.toMillis() - r.activeFrom.toMillis()).toBe(10 * 60_000);
expect(r.activeFrom.toMillis()).toBeGreaterThanOrEqual(before - 1000);
expect(r.scheduled).toBe(false);
// 진행 중 회차가 있으면 새 회차를 붙이지 않는다.
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r1b' }, { role: 'master' })))
.toBe('E-QST-01');
});
it('보상 상한(config/policy.timeAttackMaxPop) · 경로 문자 · 긴 제목을 거부한다', async () => {
const saved = (await db().doc('config/policy').get()).data();
await db().doc('config/policy').set({ timeAttackMaxPop: 100 });
try {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' });
expect(ok.questId).toBeTruthy();
} finally {
await db().doc('config/policy').delete();
}
try {
// 문서가 없으면 기본 상한 1,000.
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, rewardPop: 1001, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01');
} finally {
if (saved) await db().doc('config/policy').set(saved);
}
it('보상 상한은 초기 100(Q-TA-04 A) · 운영값으로 바꿀 수 있다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, fandomId: 'a/b', requestId: 'r7' }, { role: 'master' }))).toBe('E-QST-01');
{ ...start, rewardPop: 101, requestId: 'r4' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart, { ...start, rewardPop: 100, requestId: 'r5' }, { role: 'master' });
expect(ok.roundId).toBeTruthy();
await db().doc('config/policy').set({ timeAttackMaxPop: 50 });
await db().doc(`quests/${BASE}`).update({ timeAttack: FieldValue.delete() });
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, title: 'x'.repeat(61), requestId: 'r8' }, { role: 'master' }))).toBe('E-QST-01');
{ ...start, rewardPop: 51, requestId: 'r6' }, { role: 'master' }))).toBe('E-QST-01');
});
it('하루를 넘는 창 · 없는 팬덤은 거부한다', async () => {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 24 * 60 + 1, requestId: 'r2' }, { role: 'master' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, fandomId: 'no-such-fandom', requestId: 'r3' }, { role: 'master' }))).toBe('E-QST-01');
it('검증형 · 사용자 생성 · 다른 팬덤 Quest, 경로 문자 · 긴 제목 · 하루 초과는 거부한다', async () => {
await db().doc('quests/ta-walk').set({ type: 'walk', fandomId: FANDOM });
await db().doc('quests/ta-ugc').set({ type: 'vote', fandomId: FANDOM, authorUid: 'someone' });
await db().doc('quests/ta-else').set({ type: 'vote', fandomId: 'fandom-else' });
const master = { role: 'master' };
for (const [over, id] of [
[{ questId: 'ta-walk' }, 'a'], [{ questId: 'ta-ugc' }, 'b'], [{ questId: 'ta-else' }, 'c'],
[{ fandomId: 'a/b' }, 'd'], [{ title: 'x'.repeat(61) }, 'e'], [{ durationMinutes: 24 * 60 + 1 }, 'f'],
[{ fandomId: 'no-such-fandom' }, 'g'], [{ startAt: new Date(Date.now() - 60_000).toISOString() }, 'h'],
] as Array<[Reply, string]>) {
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, ...over, requestId: id }, master)))
.toBe('E-QST-01');
}
});
it('지속 시간 상한은 config/policy.timeAttackMaxMinutes 를 따른다', async () => {
const saved = (await db().doc('config/policy').get()).data();
await db().doc('config/policy').set({ timeAttackMaxMinutes: 30 });
try {
expect(await codeOf(() => call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 31, requestId: 'r9' }, { role: 'master' }))).toBe('E-QST-01');
const ok = await call(fnQuestTimeAttackStart,
{ ...start, durationMinutes: 30, requestId: 'r10' }, { role: 'master' });
expect(ok.questId).toBeTruthy();
} finally {
await db().doc('config/policy').delete();
if (saved) await db().doc('config/policy').set(saved);
}
it('예약 발동 — 즉시 Push 없이 시작 3분 전에 한 번 보낸다(Q-TA-03 C)', async () => {
const startAt = new Date(Date.now() + 10 * 60_000);
const reply = await call(fnQuestTimeAttackStart,
{ ...start, startAt: startAt.toISOString(), requestId: 'r7' }, { role: 'master' });
expect(reply).toMatchObject({ scheduled: true, pushed: false });
const r = (await db().doc(`quests/${BASE}`).get()).get('timeAttack');
expect(r.notifyAt.toMillis()).toBe(startAt.getTime() - 3 * 60_000);
// 알림 시각 전에는 보내지 않는다.
await notifyDueRounds(new Date(startAt.getTime() - 4 * 60_000));
expect((await db().doc(`quests/${BASE}`).get()).get('timeAttack.preNotified')).toBe(false);
// 알림 시각이 지나면 표시하고(에뮬레이터에는 FCM 이 없어 발송 수는 세지 않는다), 다시 보내지 않는다.
await notifyDueRounds(new Date(startAt.getTime() - 3 * 60_000));
expect((await db().doc(`quests/${BASE}`).get()).get('timeAttack.preNotified')).toBe(true);
expect(await notifyDueRounds(new Date(startAt.getTime() - 60_000))).toBe(0);
// 시작 전에는 참여할 수 없다.
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: BASE }))).toBe('E-QST-01');
});
});
describe('타임어택 참여 — fn-quest-timeattack', () => {
it('마감 뒤 재시도여도 이미 받은 사용자는 첫 결과를 받는다', async () => {
await quest('ta-retry', -60_000, 60_000);
const first = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
await db().doc('quests/ta-retry').update({ activeTo: Timestamp.fromMillis(Date.now() - 1) });
const again = await call(fnQuestTimeAttack, { questId: 'ta-retry' });
expect(again.txId).toBe(first.txId);
expect(again.duplicate).toBe(true);
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(1);
});
it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위로 남고 재호출은 첫 결과를 돌려준다', async () => {
await quest('ta-open', -60_000, 60_000);
describe('회차 참여 — fn-quest-timeattack', () => {
it('창 안 참여는 원장 1행 · EXP · FPI COMMUNITY 1 단위, 재호출은 첫 결과(마감 뒤도)', async () => {
await round('ta-open', -60_000, 60_000);
const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(reply.rewarded).toBe(30);
expect(reply.balanceAfter).toBe(30);
await db().doc('quests/ta-open').update({ 'timeAttack.activeTo': Timestamp.fromMillis(Date.now() - 1) });
const again = await call(fnQuestTimeAttack, { questId: 'ta-open' });
expect(again).toMatchObject({ txId: reply.txId, balanceAfter: 30, rewarded: 30, duplicate: true });
// 인정 활동 1건 = EXP 100(C-18) · 검증 활동 1회 · FPI COMMUNITY 1 단위(Q-TA-05 권고 A안).
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect(await verifiedQuestCountOf(UID)).toBe(1);
const fpi = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
expect(fpi.docs.map((d) => [d.get('axis'), d.get('units'), d.get('activity')]))
.toEqual([['community', 1, 'timeattack']]);
const ledger = await db().collection(`users/${UID}/popTransactions`).get();
expect(ledger.size).toBe(1);
expect(ledger.docs[0]!.get('reasonCode')).toBe('QUEST_TIMEATTACK');
expect((await db().doc('quests/ta-open').get()).get('participantCount')).toBe(1);
expect((await db().doc(`users/${UID}/questProgress/ta-open`).get()).get('count')).toBe(1);
});
it('같은 원 행동의 중복 FPI 금지 — 회차 참여가 원 Quest 그날 참여를 겸한다', async () => {
await round('ta-dup', -60_000, 60_000);
await joinTimeAttack(UID, 'ta-dup');
// 같은 날 일반 참여는 이미 한도.
expect(await codeOf(() => participateStreaming(UID, 'ta-dup'))).toBe('E-POP-03');
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1);
});
it('그날 원 Quest 에 이미 참여했으면 회차 보상만 받고 FPI 는 더하지 않는다', async () => {
await round('ta-after', -60_000, 60_000);
await participateStreaming(UID, 'ta-after');
const reply = await joinTimeAttack(UID, 'ta-after');
expect(reply.rewarded).toBe(30);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(1);
});
it('timeAttackFpiAxis 가 null 이면 FPI 없이 EXP 만(Q-TA-05 B안)', async () => {
await db().doc('config/policy').set({ timeAttackFpiAxis: null }, { merge: true });
try {
await quest('ta-no-fpi', -60_000, 60_000);
await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' });
} finally {
await db().doc('config/policy').update({ timeAttackFpiAxis: FieldValue.delete() });
}
await db().doc('config/policy').set({ timeAttackFpiAxis: null });
await round('ta-no-fpi', -60_000, 60_000);
await call(fnQuestTimeAttack, { questId: 'ta-no-fpi' });
expect((await db().doc(`users/${UID}`).get()).get('userExp')).toBe(100);
expect((await db().collection(`fandoms/${FANDOM}/fpiEvents`).get()).size).toBe(0);
});
it('시작 전 · 마감 후(끝 시각 포함)는 거부한다', async () => {
await quest('ta-early', 60_000, 120_000);
await quest('ta-late', -120_000, -1);
it('시작 전 · 마감 후(끝 시각 포함) · 회차 없음은 거부한다', async () => {
await round('ta-early', 60_000, 120_000);
await round('ta-late', -120_000, -1);
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-early' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-late' }))).toBe('E-QST-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: BASE }))).toBe('E-QST-01');
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
});
it('다른 팬덤 · 다른 유형 퀘스트로는 받지 못한다', async () => {
await quest('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' });
await db().doc('quests/ta-attend').set({ type: 'attend', active: true });
it('다른 팬덤 Quest 로는 받지 못한다', async () => {
await round('ta-other', -60_000, 60_000, { fandomId: 'fandom-else' });
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-other' }))).toBe('E-PERM-01');
expect(await codeOf(() => call(fnQuestTimeAttack, { questId: 'ta-attend' }))).toBe('E-QST-01');
});
});