230 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
});
|