Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
+308
View File
@@ -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);
});
});