import { afterAll, beforeAll, expect, it } from 'vitest'; import { getFirestore } from 'firebase-admin/firestore'; import { notifyEclipseTransition, notifyHeroDrop, notifyQuestDone } from '../src/notify/inbox.js'; import { bootAdmin, shutdownAdmin } from './support.js'; /** 알림함 항목 — Figma `229:11265` 표본 문구 · 명세 `518:88353` 이동 규칙의 서버 절반. */ const FANDOM = 'test-inbox-fandom'; const A = 'test-inbox-a'; const B = 'test-inbox-b'; async function inbox(uid: string) { const snap = await getFirestore().collection(`users/${uid}/notifications`).get(); return snap.docs.map((d) => d.data()); } async function wipe() { const db = getFirestore(); for (const uid of [A, B]) await db.recursiveDelete(db.doc(`users/${uid}`)); await db.recursiveDelete(db.doc(`fandoms/${FANDOM}`)); await db.doc('quests/test-inbox-quest').delete(); } beforeAll(async () => { bootAdmin(); await wipe(); const db = getFirestore(); await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS' }); for (const uid of [A, B]) await db.doc(`fandoms/${FANDOM}/members/${uid}`).set({ joinedAt: new Date() }); await db.doc('quests/test-inbox-quest').set({ title: '만보기' }); }); afterAll(async () => { await wipe(); await shutdownAdmin(); }); it('이클립스 발생 · 방어는 회원 전원에게 한 건씩, 재전달해도 늘지 않는다', async () => { expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started'); expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started'); expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'ended', result: 'success' })).toBe('defended'); // 실패 종료 · 상태 변화 없음은 알리지 않는다. expect(await notifyEclipseTransition(FANDOM, 'r2', { status: 'active' }, { status: 'ended', result: 'failed' })).toBeNull(); expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'active' })).toBeNull(); for (const uid of [A, B]) { const items = await inbox(uid); const titles = items.map((n) => n.title).sort(); expect(titles).toEqual(['내 행성의 이클립스가 발생했어요.', '내 행성의 이클립스를 방어했어요.']); expect(items.every((n) => n.eventType === 'eclipse' && n.source === 'BTS' && !n.targetPath)).toBe(true); // 앱이 단계(발생 = 위기 · 방어 = 결과)와 행성을 가른다. expect(items.map((n) => `${n.phase}:${n.fandomId}`).sort()).toEqual([`defended:${FANDOM}`, `start:${FANDOM}`]); } }); it('영웅의 선물 발동은 회원 전원에게 발동자 닉네임으로', async () => { await notifyHeroDrop(FANDOM, 'd1', '팜하니짱'); const hero = (await inbox(B)).filter((n) => n.eventType === 'item_used'); expect(hero).toHaveLength(1); expect(hero[0]!.title).toBe('팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!'); expect(hero[0]!.source).toBe('아이템'); expect(hero[0]!.fandomId).toBe(FANDOM); }); it('Quest 문서가 없는 진행 카운터(광고 일일 횟수 ad)는 퀘스트 완료로 알리지 않는다', async () => { expect(await notifyQuestDone(A, 'ad', undefined, { dayKey: '2026-10-08', count: 1 })).toBe(false); expect((await inbox(A)).filter((n) => n.eventType === 'quest')).toHaveLength(0); }); it('퀘스트 완료는 횟수가 오른 때만 본인에게, 상세 경로를 싣는다', async () => { const day = '2026-10-05'; expect(await notifyQuestDone(A, 'test-inbox-quest', undefined, { dayKey: day, count: 1 })).toBe(true); // 같은 횟수 재기록(updatedAt 만 바뀜)은 알리지 않는다. expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 1 })).toBe(false); expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 2 })).toBe(true); const quest = (await inbox(A)).filter((n) => n.eventType === 'quest'); expect(quest).toHaveLength(2); expect(quest[0]!.title).toBe('[만보기] 퀘스트를 완료했어요!'); expect(quest[0]!.targetPath).toBe('quests/test-inbox-quest'); }); it('운영 Quest 의 다국어 제목은 사용자 언어(없으면 ko)로 푼다', async () => { const db = getFirestore(); await db.doc('quests/test-inbox-quest').set({ title: { ko: '신곡 스트리밍', en: 'Stream the single' } }); await db.doc(`users/${B}`).set({ locale: 'en' }); await notifyQuestDone(A, 'test-inbox-quest', undefined, { dayKey: '2026-10-06', count: 1 }); await notifyQuestDone(B, 'test-inbox-quest', undefined, { dayKey: '2026-10-06', count: 1 }); const titles = async (uid: string) => (await inbox(uid)).filter((n) => n.eventType === 'quest').map((n) => n.title); expect(await titles(A)).toContain('[신곡 스트리밍] 퀘스트를 완료했어요!'); expect(await titles(B)).toEqual(['[Stream the single] 퀘스트를 완료했어요!']); });