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; async function call( fn: { run(request: never): unknown }, uid: string, data: Record, ): Promise { const request = { data, auth: { uid, token: {} } } as unknown as never; return (await fn.run(request)) as Reply; } async function codeOf(run: () => Promise): Promise { 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 { 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 { 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 { 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 { 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); }); });