import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest'; import { getFirestore } from 'firebase-admin/firestore'; /** C-45 이벤트별 개인 Push — 알림 설정 존중 · 단말 토큰 · 죽은 토큰 정리. FCM 은 가짜로 바꾼다. */ const sent: Array<{ tokens: string[]; notification: unknown; data: Record }> = []; vi.mock('firebase-admin/messaging', () => ({ getMessaging: () => ({ sendEachForMulticast: async (m: { tokens: string[]; notification: unknown; data: Record }) => { sent.push(m); const responses = m.tokens.map((t) => t === 'dead' ? { success: false, error: { code: 'messaging/registration-token-not-registered' } } : { success: true }); return { responses, successCount: responses.filter((r) => r.success).length, failureCount: responses.filter((r) => !r.success).length, }; }, }), })); const { pushAllowed } = await import('../src/notify/push.js'); const { pushInboxItem } = await import('../src/notify/inbox.js'); const { bootAdmin, shutdownAdmin } = await import('./support.js'); const U = 'test-push-user'; const quest = { eventType: 'quest' as const, type: 'essential' as const, title: '[만보기] 퀘스트를 완료했어요!', source: '퀘스트', targetPath: 'quests/q1', }; const eclipse = { eventType: 'eclipse' as const, type: 'fandomEvent' as const, title: '내 행성의 이클립스가 발생했어요.', source: 'BTS', }; beforeAll(() => { bootAdmin(); }); beforeEach(async () => { sent.length = 0; await getFirestore().recursiveDelete(getFirestore().doc(`users/${U}`)); }); afterAll(async () => { await getFirestore().recursiveDelete(getFirestore().doc(`users/${U}`)); await shutdownAdmin(); }); it('기본값은 Marketing 만 OFF · 서비스 필수는 끌 수 없다 (B-77)', () => { expect(pushAllowed(undefined, 'fandomEvent')).toBe(true); expect(pushAllowed(undefined, 'marketing')).toBe(false); expect(pushAllowed({ fandomEvent: false }, 'fandomEvent')).toBe(false); expect(pushAllowed({ essential: false }, 'essential')).toBe(true); expect(pushAllowed({ marketing: true }, 'marketing')).toBe(true); }); it('알림함 항목 문구 그대로 그 사용자 단말 전부에 보내고 죽은 토큰은 걷어 낸다', async () => { await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1', 'dead', 't1'] }); expect(await pushInboxItem(U, quest)).toBe('sent'); expect(sent).toHaveLength(1); expect(sent[0]!.tokens).toEqual(['t1', 'dead']); expect(sent[0]!.notification).toEqual({ title: 'FANUZU', body: '[만보기] 퀘스트를 완료했어요!' }); expect(sent[0]!.data).toMatchObject({ category: 'essential', kind: 'quest', targetPath: 'quests/q1' }); expect((await getFirestore().doc(`users/${U}`).get()).get('fcmTokens')).toEqual(['t1', 't1']); }); it('설정에서 끈 구분은 보내지 않는다 — OFF 발송 0건', async () => { await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1'], notificationPrefs: { fandomEvent: false } }); expect(await pushInboxItem(U, eclipse)).toBe('off'); expect(await pushInboxItem(U, quest)).toBe('sent'); expect(sent).toHaveLength(1); }); it('토큰이 없으면 보내지 않는다 · 구분을 모르는 항목은 건너뛴다', async () => { await getFirestore().doc(`users/${U}`).set({}); expect(await pushInboxItem(U, eclipse)).toBe('no-token'); expect(await pushInboxItem(U, { title: 'x' })).toBeNull(); expect(sent).toHaveLength(0); }); it('탭 이동 · C-78 단계에 쓰는 값을 싣는다 — 팬덤 · 이클립스 단계 · 캠페인 (v1.5 8장 Deep Link)', async () => { await getFirestore().doc(`users/${U}`).set({ fcmTokens: ['t1'] }); expect(await pushInboxItem(U, { ...eclipse, fandomId: 'bts', phase: 'defended' })).toBe('sent'); expect(await pushInboxItem(U, { eventType: 'campaign', type: 'essential', title: '정산', source: '', campaignId: 'c1' })).toBe('sent'); expect(sent[0]!.data).toEqual({ category: 'fandomEvent', fandomId: 'bts', kind: 'eclipse', phase: 'defended' }); expect(sent[1]!.data).toEqual({ category: 'essential', kind: 'campaign', campaignId: 'c1' }); });