Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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 { verifiedQuestCountOf } from '../src/ledger/fpi.js';
|
||||
|
||||
/**
|
||||
* 긴급 돌발 타임어택 — 별첨 1 ③-4 · FR-QST-06 검증 기준
|
||||
* 「발동·마감 경계 시각의 참여 수락/거부 동작 확인」.
|
||||
*/
|
||||
|
||||
const UID = 'test-timeattack';
|
||||
const FANDOM = 'fandom-timeattack';
|
||||
const db = () => getFirestore();
|
||||
|
||||
type Reply = Record<string, unknown>;
|
||||
const call = async (
|
||||
fn: { run(request: never): unknown },
|
||||
data: Record<string, unknown>,
|
||||
token: Record<string, unknown> = {},
|
||||
uid = UID,
|
||||
): Promise<Reply> =>
|
||||
(await fn.run({ data, auth: { uid, token } } as unknown as never)) as Reply;
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
return (error as { details?: { code?: string } }).details?.code ?? (error as Error).message;
|
||||
}
|
||||
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||
}
|
||||
|
||||
/** 창을 직접 박은 퀘스트. [fromMs] · [toMs] 는 지금 기준 상대 ms. */
|
||||
async function quest(id: string, fromMs: number, toMs: number, extra: Reply = {}) {
|
||||
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),
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
afterAll(async () => { await shutdownAdmin(); });
|
||||
beforeEach(async () => {
|
||||
await wipeUser(UID);
|
||||
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`));
|
||||
});
|
||||
|
||||
describe('서버 시각 — fn-server-time', () => {
|
||||
it('서버 시각(ms)을 돌려준다', async () => {
|
||||
const before = Date.now();
|
||||
const reply = await call(fnServerTime, {});
|
||||
expect(reply.now as number).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('타임어택 발동 — fn-quest-timeattack-start', () => {
|
||||
const start = { fandomId: FANDOM, title: '10분 돌발', rewardPop: 30, durationMinutes: 10 };
|
||||
|
||||
it('master 가 아니면 거부한다', async () => {
|
||||
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }))).toBe('E-PERM-01');
|
||||
expect(await codeOf(() => call(fnQuestTimeAttackStart, { ...start, requestId: 'r0' }, { role: 'ops' })))
|
||||
.toBe('E-PERM-01');
|
||||
});
|
||||
|
||||
it('창을 서버 시각으로 열고 같은 요청의 재전송은 한 건으로 모은다', 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.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);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||
{ ...start, fandomId: 'a/b', requestId: 'r7' }, { role: 'master' }))).toBe('E-QST-01');
|
||||
expect(await codeOf(() => call(fnQuestTimeAttackStart,
|
||||
{ ...start, title: 'x'.repeat(61), requestId: 'r8' }, { 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('지속 시간 상한은 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const reply = await call(fnQuestTimeAttack, { questId: 'ta-open' });
|
||||
expect(reply.rewarded).toBe(30);
|
||||
expect(reply.balanceAfter).toBe(30);
|
||||
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('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() });
|
||||
}
|
||||
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);
|
||||
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 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 });
|
||||
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