import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { distanceMeters, gradeShake } from '../src/quest/verify.js'; import { grantPop, refundPop, spendPop } from '../src/ledger/ledger.js'; /** * 5~10주차 함수의 불변식 검증. * * 화면 없이도 확인할 수 있는 것만 본다 — 판정식, 중복 차단, 환불 무결성. * 스케줄 함수의 전체 왕복은 배포 뒤 확인한다. */ const UID = 'test-ext-user'; const OTHER = 'test-ext-other'; beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { await wipeUser(UID); await wipeUser(OTHER); }); describe('Music Shake 판정 (E-02)', () => { it('판정표가 스스로 모순되면 실격이다', () => { // perfect+good+miss 가 구간 수와 다르면 표가 조작되었거나 깨진 것이다. expect(gradeShake({ perfect: 10, good: 0, miss: 0, maxCombo: 10 }, 20)).toBe('F'); }); it('빈틈없이 맞히면 S 다', () => { expect(gradeShake({ perfect: 20, good: 0, miss: 0, maxCombo: 20 }, 20)).toBe('S'); }); it('한 번이라도 놓치면 S 가 아니다', () => { expect(gradeShake({ perfect: 19, good: 0, miss: 1, maxCombo: 19 }, 20)).toBe('A'); }); it('절반도 못 맞히면 실격이다', () => { expect(gradeShake({ perfect: 5, good: 2, miss: 13, maxCombo: 3 }, 20)).toBe('F'); }); it('구간이 0이면 실격이다', () => { expect(gradeShake({ perfect: 0, good: 0, miss: 0, maxCombo: 0 }, 0)).toBe('F'); }); }); describe('방문 거리 (E-06)', () => { it('같은 좌표는 0 이다', () => { expect(distanceMeters(37.5, 127.0, 37.5, 127.0)).toBe(0); }); it('서울 안 두 지점의 거리를 백 미터 단위로 맞춘다', () => { // 강남역 ↔ 역삼역, 실측 약 1.1km. const d = distanceMeters(37.4979, 127.0276, 37.5006, 127.0365); expect(d).toBeGreaterThan(800); expect(d).toBeLessThan(1400); }); it('위도 1도는 약 111km 다', () => { const d = distanceMeters(37.0, 127.0, 38.0, 127.0); expect(d).toBeGreaterThan(110_000); expect(d).toBeLessThan(112_000); }); }); describe('현상금 — 예치·전송·환불 (별첨 1 ④)', () => { it('예치·전송·환불이 모두 원장을 지나 잔액이 재현된다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 1000, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna', }); const deposit = await spendPop({ uid: UID, popType: 'activity', amount: 500, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:1', }); expect(deposit.balanceAfter).toBe(500); // 채택 — 답변자에게 지급. const paid = await grantPop({ uid: OTHER, popType: 'activity', amount: 500, reasonCode: 'QUEST_ATTEND', idempotencyKey: 'accept:q1', }); expect(paid.balanceAfter).toBe(500); const db = getFirestore(); const mine = await db.doc(`users/${UID}/wallet/main`).get(); const theirs = await db.doc(`users/${OTHER}/wallet/main`).get(); // 예치한 만큼 정확히 옮겨 갔다 — 만들어지거나 사라진 POP 이 없다. expect(mine.get('activityBalance') + theirs.get('activityBalance')).toBe(1000); }); it('기한 만료 환불은 한 번만 나간다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 1000, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:qna2', }); const deposit = await spendPop({ uid: UID, popType: 'activity', amount: 300, reasonCode: 'QNA_BOUNTY', idempotencyKey: 'bounty:u:2', }); await refundPop({ uid: UID, originalTxId: deposit.txId, idempotencyKey: 'qnarefund:q2', reasonCode: 'QNA_REFUND', }); await expect(refundPop({ uid: UID, originalTxId: deposit.txId, idempotencyKey: 'qnarefund:q2-again', reasonCode: 'QNA_REFUND', })).rejects.toMatchObject({ details: { code: 'E-POP-03' } }); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('activityBalance')).toBe(1000); }); }); describe('캠페인 환불 — 부분 실패를 덮지 않는다 (별첨 1 ⑤)', () => { it('후원 한 건당 한 번만 돌려준다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 1000, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp', }); const donation = await spendPop({ uid: UID, popType: 'activity', amount: 400, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:1', }); const key = 'refund:donation-1'; const first = await refundPop({ uid: UID, originalTxId: donation.txId, idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND', }); // 배치를 다시 돌려도 같은 거래로 판정된다. const again = await refundPop({ uid: UID, originalTxId: donation.txId, idempotencyKey: key, reasonCode: 'CAMPAIGN_REFUND', }); expect(again.txId).toBe(first.txId); const wallet = await getFirestore().doc(`users/${UID}/wallet/main`).get(); expect(wallet.get('activityBalance')).toBe(1000); }); it('환불하면 기여 통계도 되돌아간다', async () => { await grantPop({ uid: UID, popType: 'activity', amount: 1000, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cmp2', }); const donation = await spendPop({ uid: UID, popType: 'activity', amount: 400, reasonCode: 'CAMPAIGN_FUND', idempotencyKey: 'fund:cmp:2', fandomId: 'f1', }); const db = getFirestore(); expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(400); await refundPop({ uid: UID, originalTxId: donation.txId, idempotencyKey: 'refund:donation-2', reasonCode: 'CAMPAIGN_REFUND', }); expect((await db.doc(`users/${UID}`).get()).get('totalContributedPop')).toBe(0); }); }); describe('영웅의 선물 — 문서 ID 로 중복을 막는다', () => { it('같은 사람이 두 번 수령하지 못한다', async () => { const db = getFirestore(); const dropRef = db.doc('fandoms/f1/heroDrops/d1'); await dropRef.set({ totalPop: 1000, perUserCap: 100, remainingPop: 1000, endsAt: Timestamp.fromMillis(Date.now() + 60_000), createdAt: FieldValue.serverTimestamp(), }); const claimRef = dropRef.collection('claims').doc(UID); await claimRef.create({ uid: UID, amount: 100 }); // 조건문이 아니라 구조로 막힌다 — 동시 요청에서도 뚫리지 않는다. await expect(claimRef.create({ uid: UID, amount: 100 })).rejects.toThrow(); await dropRef.delete(); await claimRef.delete(); }); }); describe('추천 — 자기 추천과 중복 전환', () => { it('같은 사람이 같은 코드로 두 번 전환되지 않는다', async () => { const db = getFirestore(); const ref = db.doc(`referrals/${UID}_CODE1`); await ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' }); await expect( ref.create({ referredUid: UID, referrerUid: OTHER, code: 'CODE1' }), ).rejects.toThrow(); await ref.delete(); }); }); describe('번역 — 자기검증 제외', () => { it('자기 마킹은 유효 표에서 빠진다', () => { const author = 'author'; const markings = [ { id: author, valid: true }, { id: 'a', valid: true }, { id: 'b', valid: false }, { id: 'c', valid: false }, ]; const valid = markings.filter((m) => m.id !== author && m.valid).length; const invalid = markings.filter((m) => m.id !== author && !m.valid).length; // 자기 마킹을 세면 2:2 로 통과하지만, 빼면 1:2 로 무효다. expect(valid).toBe(1); expect(invalid).toBe(2); expect(invalid > valid).toBe(true); }); });