광고 추가 보상 서버 시청 검증 · 사용자 퀘스트 승인 후 공개 · TIME ATTACK 모드 · 만보기 퀘스트 · 위치 퀘스트 지도 선택 · 운영 퀘스트 보상 재원
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user