Files

230 lines
8.8 KiB
TypeScript

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, videoIdOf } from '../src/quest/verify.js';
import { DEFAULT_POLICY } from '../src/core/config.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');
});
it('성공 문턱 · 등급 문턱은 운영값이다 — 🟡 임시값 회신 대기 표시가 붙어 있다', () => {
expect(DEFAULT_POLICY.musicShake.provisional).toBe(true);
const strict = { ...DEFAULT_POLICY.musicShake, passRate: 0.9 };
// 기본 문턱(0.5)이면 C, 운영값을 0.9 로 올리면 실패다.
expect(gradeShake({ perfect: 6, good: 6, miss: 8, maxCombo: 6 }, 20)).toBe('C');
expect(gradeShake({ perfect: 6, good: 6, miss: 8, maxCombo: 6 }, 20, strict)).toBe('F');
});
it('지정 공식 영상 링크에서 영상 ID 를 꺼낸다', () => {
expect(videoIdOf('https://www.youtube.com/watch?v=WMweEpGlu_U')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://youtu.be/WMweEpGlu_U?si=x')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://music.youtube.com/watch?v=WMweEpGlu_U')).toBe('WMweEpGlu_U');
expect(videoIdOf('https://vimeo.com/1')).toBeNull();
expect(videoIdOf(undefined)).toBeNull();
});
});
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);
});
});