import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin } from './support.js'; import { awardActivityBadges, refreshPassport } from '../src/account/passport.js'; import { awardEvolutionEmblems } from '../src/fandom/evolution.js'; import { grantPop } from '../src/ledger/ledger.js'; /** 팬덤별 Passport Tier · 활동 Badge · 진화 Emblem — 상세정의 4장 · 22장 · 9/21 5절 · 10/3 #10. */ const uid = 'test-passport-user'; const other = 'test-passport-other'; const a = 'test-passport-a'; const b = 'test-passport-b'; const DAY = 86_400_000; async function clean() { const db = getFirestore(); for (const path of [`users/${uid}`, `users/${other}`, `fandoms/${a}`, `fandoms/${b}`]) { await db.recursiveDelete(db.doc(path)); } await db.recursiveDelete(db.doc('config/passportTierRules')); } beforeAll(() => { bootAdmin(); }); beforeEach(async () => { await clean(); const db = getFirestore(); await db.doc(`users/${uid}`).set({ userLevel: 30, fandomIds: [a, b], primaryFandomId: a }); for (const f of [a, b]) { await db.doc(`fandoms/${f}/members/${uid}`).set({ uid, fandomId: f, memberNo: 7, contributedPop: 0, joinedAt: Timestamp.fromMillis(Date.now() - 40 * DAY), }); } }); afterAll(async () => { await clean(); await shutdownAdmin(); }); it('활동 카운터가 7단계 문턱을 넘으면 Badge 단계가 오르고 내려가지 않는다', async () => { const db = getFirestore(); await awardActivityBadges(uid, { health: 120, bpm: 5 }); const health = await db.doc(`users/${uid}/badges/activity_health`).get(); expect(health.get('stage')).toBe(2); expect((await db.doc(`users/${uid}/badges/activity_bpm`).get()).exists).toBe(false); await awardActivityBadges(uid, { health: 10 }); expect((await db.doc(`users/${uid}/badges/activity_health`).get()).get('stage')).toBe(2); }); it('Tier 는 팬덤별 — 같은 사용자라도 기여가 다른 팬덤은 Tier 가 따로다', async () => { const db = getFirestore(); await awardActivityBadges(uid, { health: 500, community: 500 }); await db.doc('config/passportTierRules').set({ rules: [ { tier: 'GOLD', minUserLevel: 30, minActiveDays: 30, minBadges: 1, minBadgeStage: 1, minContributedPop: 0, enabled: true }, { tier: 'DIAMOND', minUserLevel: 30, minActiveDays: 30, minBadges: 2, minBadgeStage: 3, minContributedPop: 1_000, enabled: true }, ] }); await db.doc(`fandoms/${b}/members/${uid}`).update({ contributedPop: 1_000 }); expect(await refreshPassport(uid, a)).toBe('GOLD'); expect(await refreshPassport(uid, b)).toBe('DIAMOND'); const pa = await db.doc(`users/${uid}/passports/${a}`).get(); expect(pa.get('tier')).toBe('GOLD'); expect(pa.get('memberNo')).toBe(7); expect(pa.get('activeDays')).toBeGreaterThanOrEqual(39); // users 문서에는 단일 Tier 를 두지 않는다. expect((await db.doc(`users/${uid}`).get()).get('passportTier')).toBeUndefined(); }); it('탈퇴 후에도 Passport 는 남고 강등하지 않으며 재가입하면 이어 쓴다', async () => { const db = getFirestore(); await awardActivityBadges(uid, { health: 10 }); expect(await refreshPassport(uid, a)).toBe('GOLD'); await db.doc(`fandoms/${a}/members/${uid}`).delete(); expect(await refreshPassport(uid, a)).toBe('GOLD'); await db.doc(`users/${uid}`).update({ userLevel: 1 }); await db.doc(`fandoms/${a}/members/${uid}`).set({ uid, memberNo: 99, contributedPop: 0, joinedAt: Timestamp.now() }); expect(await refreshPassport(uid, a)).toBe('GOLD'); const pa = await db.doc(`users/${uid}/passports/${a}`).get(); // 첫 가입 순번 · 시각을 그대로 둔다 — 새 Passport 로 초기화하지 않는다. expect(pa.get('memberNo')).toBe(7); expect(pa.get('activeDays')).toBeGreaterThanOrEqual(39); }); it('인정 활동은 FPI 축별 활동 카운터를 올린다', async () => { const db = getFirestore(); await grantPop({ uid, popType: 'activity', amount: 10, reasonCode: 'QUEST_ATTEND', growthSource: 'quest_reward', idempotencyKey: 'passport:attend' }); // 지급만으로는 FPI 활동이 아니다 — 카운터는 FPI 이벤트를 쓰는 경로가 올린다. const { writeFpiEvent } = await import('../src/ledger/fpi.js'); await db.runTransaction(async (tx) => { const user = await tx.get(db.doc(`users/${uid}`)); writeFpiEvent(tx, { uid, fandomId: a, axis: 'health', units: 2, activity: 'steps', sourceId: 'passport-s1', occurredAt: new Date() }, user); }); expect((await db.doc(`users/${uid}`).get()).get('activityCounts')).toEqual({ health: 2 }); }); it('진화 기념 Emblem 은 그 주기에 성장 기여한 사용자에게만, 참여자 알림과 함께', async () => { const db = getFirestore(); const since = Timestamp.fromMillis(Date.now() - DAY); await db.doc(`users/${uid}/growthEvents/g1`).set({ fandomIdAtPayout: a, amount: 10, reversalOf: null, createdAt: Timestamp.now(), }); // 주기 이전 기여 · 다른 팬덤 기여는 제외. await db.doc(`users/${other}/growthEvents/g0`).set({ fandomIdAtPayout: a, amount: 10, reversalOf: null, createdAt: Timestamp.fromMillis(Date.now() - 3 * DAY), }); await db.doc(`users/${other}/growthEvents/g2`).set({ fandomIdAtPayout: b, amount: 10, reversalOf: null, createdAt: Timestamp.now(), }); const uids = await awardEvolutionEmblems({ fandomId: a, name: '테스트', fromStage: 1, toStage: 2, since, until: Timestamp.fromMillis(Date.now() + 1000), }); expect(uids).toEqual([uid]); const emblem = await db.doc(`users/${uid}/badges/evolution_${a}_2`).get(); expect(emblem.get('kind')).toBe('evolution'); expect(emblem.get('toStage')).toBe(2); expect((await db.doc(`users/${other}/badges/evolution_${a}_2`).get()).exists).toBe(false); const note = await db.doc(`users/${uid}/notifications/evolution_${a}_2`).get(); expect(note.get('body')).toContain('당신도 이 진화에 함께했습니다'); // 재전달에도 한 번. await awardEvolutionEmblems({ fandomId: a, fromStage: 1, toStage: 2, since, until: Timestamp.fromMillis(Date.now() + 1000), }); expect((await db.collection(`users/${uid}/badges`).get()).size).toBe(1); });