Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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 { grantPop } from '../src/ledger/ledger.js';
|
||||
import {
|
||||
countAnswer, expireBounties, fnAnswerAccept, fnQuestionCreate, MAX_REFUND_ATTEMPTS,
|
||||
} from '../src/inside/qna.js';
|
||||
|
||||
/**
|
||||
* 현상금 Q&A 왕복 — 별첨 1 ④ 「예치·전송·환불 원장 정합성 오류 0건」.
|
||||
* 핸들러를 `CallableFunction.run` 으로 그대로 돌린다(quest_e2e 와 같은 방식).
|
||||
*/
|
||||
|
||||
const ASKER = 'test-qna-asker';
|
||||
const ANSWERER = 'test-qna-answerer';
|
||||
|
||||
type Reply = Record<string, unknown>;
|
||||
|
||||
async function call(
|
||||
fn: { run(request: never): unknown },
|
||||
uid: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Reply> {
|
||||
const request = { data, auth: { uid, token: {} } } as unknown as never;
|
||||
return (await fn.run(request)) as Reply;
|
||||
}
|
||||
|
||||
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 ?? String(error);
|
||||
}
|
||||
throw new Error('실패해야 하는 호출이 성공했습니다.');
|
||||
}
|
||||
|
||||
async function balance(uid: string): Promise<number> {
|
||||
const w = await getFirestore().doc(`users/${uid}/wallet/main`).get();
|
||||
return (w.get('activityBalance') as number | undefined) ?? 0;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
async function ask(bountyPop: number): Promise<string> {
|
||||
const reply = await call(fnQuestionCreate, ASKER, {
|
||||
title: '질문', body: '본문', bountyPop, clientSeq: `s${++seq}-${Date.now()}`,
|
||||
});
|
||||
return reply.questionId as string;
|
||||
}
|
||||
|
||||
async function answer(questionId: string, uid: string): Promise<string> {
|
||||
const ref = await getFirestore().collection(`questions/${questionId}/answers`).add({
|
||||
authorUid: uid, authorName: uid, body: '답', accepted: false,
|
||||
});
|
||||
return ref.id;
|
||||
}
|
||||
|
||||
beforeAll(() => { bootAdmin(); });
|
||||
afterAll(async () => { await shutdownAdmin(); });
|
||||
beforeEach(async () => {
|
||||
await wipeUser(ASKER);
|
||||
await wipeUser(ANSWERER);
|
||||
const old = await getFirestore().collection('questions').where('authorUid', '==', ASKER).get();
|
||||
await Promise.all(old.docs.map((d) => getFirestore().recursiveDelete(d.ref)));
|
||||
await getFirestore().doc(`users/${ASKER}`).set({ displayName: '김실명', nickname: '질문자', fandomId: 'f-qna' });
|
||||
await grantPop({
|
||||
uid: ASKER, popType: 'activity', amount: 1000,
|
||||
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:qna:${Date.now()}`,
|
||||
});
|
||||
});
|
||||
|
||||
describe('fn-question-create', () => {
|
||||
it('예치와 질문이 함께 서고, 목록이 읽는 팬덤·표시명이 복제된다', async () => {
|
||||
const id = await ask(300);
|
||||
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||
expect(q.get('status')).toBe('open');
|
||||
expect(q.get('fandomId')).toBe('f-qna');
|
||||
expect(q.get('authorName')).toBe('질문자');
|
||||
});
|
||||
|
||||
it('닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => {
|
||||
await getFirestore().doc(`users/${ASKER}`).update({ nickname: FieldValue.delete() });
|
||||
const q = await getFirestore().doc(`questions/${await ask(300)}`).get();
|
||||
expect(q.get('authorName')).toBe('팬');
|
||||
expect(q.get('bountyPop')).toBe(300);
|
||||
expect(await balance(ASKER)).toBe(700);
|
||||
});
|
||||
|
||||
it('제목 · 본문 길이 상한을 서버가 끊는다', async () => {
|
||||
const run = (title: string, body: string) => call(fnQuestionCreate, ASKER, {
|
||||
title, body, bountyPop: 100, clientSeq: `len-${Date.now()}`,
|
||||
});
|
||||
expect(await codeOf(() => run('가'.repeat(101), '본문'))).toBe('invalid-argument');
|
||||
expect(await codeOf(() => run('제목', '가'.repeat(5_001)))).toBe('invalid-argument');
|
||||
expect(await balance(ASKER)).toBe(1000);
|
||||
});
|
||||
|
||||
it('현상금 100 POP 미만은 서버가 거부한다', async () => {
|
||||
expect(await codeOf(() => ask(99))).toBe('invalid-argument');
|
||||
expect(await balance(ASKER)).toBe(1000);
|
||||
});
|
||||
|
||||
it('현상금 하한은 config/policy.qnaMinBountyPop 이 덮는다', async () => {
|
||||
const policy = getFirestore().doc('config/policy');
|
||||
await policy.set({ qnaMinBountyPop: 200 }, { merge: true });
|
||||
try {
|
||||
expect(await codeOf(() => ask(150))).toBe('invalid-argument');
|
||||
expect(await ask(200)).toBeTruthy();
|
||||
} finally {
|
||||
await policy.set({ qnaMinBountyPop: FieldValue.delete() }, { merge: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('일반이 모자라면 부족분만 기여 POP 으로 예치하고, 만료 환불은 각 타입으로 돌아간다(B-39)', async () => {
|
||||
await grantPop({
|
||||
uid: ASKER, popType: 'support', amount: 500,
|
||||
reasonCode: 'IAP_PURCHASE', idempotencyKey: `s:qna:${Date.now()}`,
|
||||
});
|
||||
const support = async () =>
|
||||
((await getFirestore().doc(`users/${ASKER}/wallet/main`).get()).get('supportBalance') as number);
|
||||
const id = await ask(1200);
|
||||
expect(await balance(ASKER)).toBe(0);
|
||||
expect(await support()).toBe(300);
|
||||
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||
expect((q.get('bountyTxIds') as string[]).length).toBe(2);
|
||||
|
||||
await q.ref.update({ expiresAt: Timestamp.fromMillis(Date.now() - 1000) });
|
||||
await expireBounties();
|
||||
await expireBounties();
|
||||
expect((await q.ref.get()).get('status')).toBe('expired');
|
||||
expect(await balance(ASKER)).toBe(1000);
|
||||
expect(await support()).toBe(500);
|
||||
const refunds = await getFirestore().collection(`users/${ASKER}/popTransactions`)
|
||||
.where('reasonCode', '==', 'QNA_REFUND').get();
|
||||
expect(refunds.docs.map((d) => [d.get('popType'), d.get('amount')]).sort())
|
||||
.toEqual([['activity', 1000], ['support', 200]]);
|
||||
});
|
||||
|
||||
it('분할 전 옛 질문(bountyTxId 한 건)도 만료 환불된다', async () => {
|
||||
const id = await ask(300);
|
||||
const ref = getFirestore().doc(`questions/${id}`);
|
||||
const [txId] = (await ref.get()).get('bountyTxIds') as string[];
|
||||
await ref.update({
|
||||
bountyTxIds: FieldValue.delete(), bountyTxId: txId,
|
||||
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||
});
|
||||
await expireBounties();
|
||||
expect((await ref.get()).get('status')).toBe('expired');
|
||||
expect(await balance(ASKER)).toBe(1000);
|
||||
});
|
||||
|
||||
it('잔액이 모자라면 질문이 남지 않는다', async () => {
|
||||
expect(await codeOf(() => ask(5000))).toBe('E-POP-01');
|
||||
const left = await getFirestore().collection('questions')
|
||||
.where('authorUid', '==', ASKER).where('bountyPop', '==', 5000).get();
|
||||
expect(left.empty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fn-answer-accept', () => {
|
||||
it('채택하면 현상금이 답변자에게 일반 POP 으로 가고 번복되지 않는다', async () => {
|
||||
const id = await ask(400);
|
||||
const a = await answer(id, ANSWERER);
|
||||
await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a });
|
||||
|
||||
expect(await balance(ANSWERER)).toBe(400);
|
||||
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
|
||||
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||
expect(q.get('status')).toBe('accepted');
|
||||
const ans = await getFirestore().doc(`questions/${id}/answers/${a}`).get();
|
||||
expect(ans.get('accepted')).toBe(true);
|
||||
|
||||
const b = await answer(id, 'someone-else');
|
||||
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: b })))
|
||||
.toBe('E-QNA-03');
|
||||
expect(await balance(ANSWERER)).toBe(400);
|
||||
});
|
||||
|
||||
it('자기 답변은 채택할 수 없다(E-QNA-01)', async () => {
|
||||
const id = await ask(200);
|
||||
const mine = await answer(id, ASKER);
|
||||
expect(await codeOf(() => call(fnAnswerAccept, ASKER, { questionId: id, answerId: mine })))
|
||||
.toBe('E-QNA-01');
|
||||
});
|
||||
|
||||
it('질문자가 아니면 채택할 수 없다', async () => {
|
||||
const id = await ask(200);
|
||||
const a = await answer(id, ANSWERER);
|
||||
expect(await codeOf(() => call(fnAnswerAccept, ANSWERER, { questionId: id, answerId: a })))
|
||||
.toBe('E-PERM-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fn-question-bounty-expire', () => {
|
||||
it('기한이 지나면 원타입으로 한 번만 환불되고, 이후 채택은 E-QNA-02', async () => {
|
||||
const id = await ask(500);
|
||||
const a = await answer(id, ANSWERER);
|
||||
await getFirestore().doc(`questions/${id}`).update({
|
||||
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||
});
|
||||
|
||||
const early = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
|
||||
.catch((e: { details?: { code?: string }; message: string }) => e);
|
||||
expect(early).toMatchObject({ details: { code: 'E-QNA-02' } });
|
||||
expect((early as { message: string }).message).toContain('환불될 예정');
|
||||
|
||||
await expireBounties();
|
||||
await expireBounties();
|
||||
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||
expect(q.get('status')).toBe('expired');
|
||||
expect(await balance(ASKER)).toBe(1000);
|
||||
expect(await balance(ANSWERER)).toBe(0);
|
||||
const late = await call(fnAnswerAccept, ASKER, { questionId: id, answerId: a })
|
||||
.catch((e: { message: string }) => e);
|
||||
expect((late as { message: string }).message).toContain('환불되었습니다');
|
||||
});
|
||||
});
|
||||
|
||||
/** 두 사람 사이의 원장 행 수(초기 지급 제외). 예치 1 + 채택 또는 환불 1 이어야 한다. */
|
||||
async function movements(): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const uid of [ASKER, ANSWERER]) {
|
||||
const rows = await getFirestore().collection(`users/${uid}/popTransactions`).get();
|
||||
for (const r of rows.docs) {
|
||||
const code = r.get('reasonCode') as string;
|
||||
if (code !== 'ADMIN_GRANT') out.push(code);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
describe('채택과 만료 환불의 경합', () => {
|
||||
it('동시에 돌아도 채택 또는 환불 중 정확히 하나만 원장을 움직인다', async () => {
|
||||
const id = await ask(500);
|
||||
const a = await answer(id, ANSWERER);
|
||||
// 기한 직전에 둘이 겹치는 상황 — 만료 시각을 막 지난 값으로 둔다.
|
||||
await getFirestore().doc(`questions/${id}`).update({
|
||||
expiresAt: Timestamp.fromMillis(Date.now() + 300),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
await Promise.allSettled([
|
||||
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
|
||||
expireBounties(),
|
||||
]);
|
||||
|
||||
const moved = (await movements()).filter((c) => c !== 'QNA_BOUNTY');
|
||||
expect(moved).toHaveLength(1);
|
||||
expect((await balance(ASKER)) + (await balance(ANSWERER))).toBe(1000);
|
||||
});
|
||||
|
||||
it('기한 전 동시 채택은 한쪽만 지급된다', async () => {
|
||||
const id = await ask(500);
|
||||
const a = await answer(id, ANSWERER);
|
||||
await Promise.allSettled([
|
||||
call(fnAnswerAccept, ASKER, { questionId: id, answerId: a }),
|
||||
expireBounties(),
|
||||
]);
|
||||
expect(await movements()).toEqual(['QNA_BOUNTY', 'QNA_REWARD']);
|
||||
expect(await balance(ANSWERER)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('환불 실패 처리', () => {
|
||||
it(`${MAX_REFUND_ATTEMPTS}번 실패하면 refund_failed 로 멈추고 운영 로그를 남긴다`, async () => {
|
||||
const id = await ask(200);
|
||||
// 원거래를 가리키는 ID 를 깨뜨려 환불이 계속 실패하게 한다.
|
||||
await getFirestore().doc(`questions/${id}`).update({
|
||||
bountyTxIds: ['missing-tx'],
|
||||
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||
});
|
||||
for (let i = 0; i < MAX_REFUND_ATTEMPTS + 1; i++) await expireBounties();
|
||||
|
||||
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||
expect(q.get('status')).toBe('refund_failed');
|
||||
expect(q.get('refundAttempts')).toBe(MAX_REFUND_ATTEMPTS);
|
||||
const logs = await getFirestore().collection('adminActionLogs')
|
||||
.where('targetPath', '==', `questions/${id}`).get();
|
||||
expect(logs.size).toBe(1);
|
||||
});
|
||||
|
||||
it('질문자 정보가 없으면 바로 refund_failed 다', async () => {
|
||||
const id = await ask(200);
|
||||
await getFirestore().doc(`questions/${id}`).update({
|
||||
bountyTxIds: [],
|
||||
expiresAt: Timestamp.fromMillis(Date.now() - 1000),
|
||||
});
|
||||
await expireBounties();
|
||||
expect((await getFirestore().doc(`questions/${id}`).get()).get('status')).toBe('refund_failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('답변 수 트리거', () => {
|
||||
it('같은 답변이 재전달되어도 한 번만 센다', async () => {
|
||||
const id = await ask(100);
|
||||
const a = await answer(id, ANSWERER);
|
||||
expect(await countAnswer(id, a)).toBe(true);
|
||||
expect(await countAnswer(id, a)).toBe(false);
|
||||
const b = await answer(id, 'someone-else');
|
||||
await Promise.all([countAnswer(id, b), countAnswer(id, b)]);
|
||||
const q = await getFirestore().doc(`questions/${id}`).get();
|
||||
expect(q.get('answerCount')).toBe(2);
|
||||
});
|
||||
|
||||
it('질문이 없으면 건너뛴다', async () => {
|
||||
expect(await countAnswer('no-such-question', 'x')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user