import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import type { CallableRequest } from 'firebase-functions/v2/https'; import { bootAdmin, shutdownAdmin } from './support.js'; import { candidateDocId, fnBriefingIngest, fnBriefingPublish, validateCandidate, } from '../src/briefing/briefing.js'; import { dayKey } from '../src/core/time.js'; import { fandomTopic, notifyEclipseWarning } from '../src/notify/push.js'; /** * AI Daily Briefing 검증 — 정책 원장 B-47 · B-48 · B-75. * * 보는 것은 셋이다. * - **수신이 5 요소를 강제하는가**(B-75 「메타 누락 건은 Draft 단계에서 배제」) * - **수신이 draft 로만 끝나는가**(B-47 「AI Draft 자동게시 금지」) * - **승인 권한이 함수에 실제로 붙어 있는가** * * 실제 FCM 발송은 확인하지 않는다 — 배포 전이고 에뮬레이터에 FCM 이 없다. */ const FANDOM = 'test-brief-fandom'; /** 5 요소를 모두 갖춘 정상 후보. 각 검증이 여기서 한 값만 빼서 쓴다. */ function candidate(overrides: Record = {}): Record { return { fandomId: FANDOM, section: 'News', title: '데뷔 10주년 단독 콘서트 확정', sourceName: '연합뉴스', sourceAt: '2026-09-14T01:20:00Z', sourceUrl: 'https://example.com/news/1', articleUrl: 'https://example.com/news/1', aiSummary: '서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다.', ...overrides, }; } const req = (role: string | undefined, data: unknown) => ({ auth: role ? { uid: 'admin-1', token: { role } } : undefined, data, }) as unknown as CallableRequest; /** 섹션별 rank 목록. 정렬해 두어야 비교가 흔들리지 않는다. */ function ranksBySection( docs: FirebaseFirestore.QueryDocumentSnapshot[], ): Record { const bySection: Record = {}; for (const doc of docs) { const section = doc.get('section') as string; bySection[section] = [...(bySection[section] ?? []), doc.get('rank') as number]; } for (const key of Object.keys(bySection)) bySection[key]!.sort((a, b) => a - b); return bySection; } beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { const db = getFirestore(); const briefs = await db.collection('briefings') .where('fandomId', '==', FANDOM).get(); await Promise.all(briefs.docs.map((d) => d.ref.delete())); // 작업 로그도 지운다. 남겨 두면 게시 검증의 로그 건수가 회차마다 늘어난다. const logs = await db.collection('adminActionLogs') .where('targetType', '==', 'briefing').get(); await Promise.all(logs.docs.map((d) => d.ref.delete())); const eclipses = await db.collection(`fandoms/${FANDOM}/eclipseRounds`).get(); await Promise.all(eclipses.docs.map((d) => d.ref.delete())); await db.doc(`fandoms/${FANDOM}`).set({ name: '테스트행성', stage: 1 }); }); describe('B-75 5 요소 검증', () => { it('다 갖춘 후보를 통과시킨다', () => { expect(validateCandidate(candidate()).ok).toBe(true); }); it('5 요소 중 하나만 빠져도 거부한다', () => { for (const key of [ 'title', 'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary', ]) { const result = validateCandidate(candidate({ [key]: undefined })); expect(result.ok, key).toBe(false); if (!result.ok) expect(result.reason, key).toContain(key); } }); it('빈 문자열과 공백을 누락으로 본다 — 수집기가 흔히 채워 보낸다', () => { for (const value of ['', ' ']) { expect(validateCandidate(candidate({ sourceName: value })).ok).toBe(false); } }); it('빠진 것을 한 번에 모아서 알린다', () => { const result = validateCandidate( candidate({ sourceUrl: undefined, aiSummary: undefined }), ); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toContain('sourceUrl'); expect(result.reason).toContain('aiSummary'); } }); it('섹션은 News 와 SNS 만 받는다', () => { expect(validateCandidate(candidate({ section: 'SNS' })).ok).toBe(true); for (const bad of ['activity', 'news', '', undefined]) { expect(validateCandidate(candidate({ section: bad })).ok, String(bad)).toBe(false); } }); it('시각 형식과 URL 형태까지 본다', () => { expect(validateCandidate(candidate({ sourceAt: '어제' })).ok).toBe(false); expect(validateCandidate(candidate({ articleUrl: 'javascript:alert(1)' })).ok) .toBe(false); expect(validateCandidate(candidate({ sourceUrl: 'example.com/1' })).ok).toBe(false); }); it('같은 원본 URL 은 같은 문서 ID 로 모인다', () => { const a = validateCandidate(candidate()); const b = validateCandidate(candidate({ title: '제목만 바뀐 같은 기사' })); expect(a.ok && b.ok).toBe(true); if (a.ok && b.ok) { expect(candidateDocId(a.value, '2026-09-14')) .toBe(candidateDocId(b.value, '2026-09-14')); } }); }); describe('수신 — fn-briefing-ingest (export 직접 호출)', () => { it('통과 건만 저장하고 거부 사유를 함께 돌려준다', async () => { const result = await fnBriefingIngest.run(req('ops', { items: [ candidate(), candidate({ section: 'SNS', sourceUrl: 'https://example.com/sns/1' }), candidate({ sourceName: undefined, sourceUrl: 'https://example.com/news/2' }), ], })); expect(result.accepted).toBe(2); expect(result.rejected).toHaveLength(1); expect(result.rejected[0]!.index).toBe(2); expect(result.rejected[0]!.reason).toContain('sourceName'); const docs = await getFirestore().collection('briefings') .where('fandomId', '==', FANDOM).get(); expect(docs.size).toBe(2); }); it('수신은 언제나 draft 로 끝난다 — 자동 게시 0 건', async () => { await fnBriefingIngest.run(req('master', { items: [candidate()] })); const docs = await getFirestore().collection('briefings') .where('fandomId', '==', FANDOM).get(); expect(docs.size).toBe(1); const doc = docs.docs[0]!; expect(doc.get('status')).toBe('draft'); expect(doc.get('aiGenerated')).toBe(true); expect(doc.get('dayKey')).toBe(dayKey(new Date())); // B-75 5 요소가 문서에 전건 남아 있어야 화면이 그릴 수 있다. for (const field of [ 'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary', ]) { expect(doc.get(field), field).toBeTruthy(); } }); it('rank 를 섹션마다 따로 매긴다', async () => { await fnBriefingIngest.run(req('ops', { items: [ candidate({ sourceUrl: 'https://example.com/n/1' }), candidate({ sourceUrl: 'https://example.com/n/2' }), candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }), ], })); const docs = await getFirestore().collection('briefings') .where('fandomId', '==', FANDOM).get(); expect(ranksBySection(docs.docs)).toEqual({ News: [1, 2], SNS: [1] }); }); it('같은 회차를 두 번 받아도 문서가 늘지 않는다', async () => { const items = [candidate()]; await fnBriefingIngest.run(req('ops', { items })); await fnBriefingIngest.run(req('ops', { items })); const docs = await getFirestore().collection('briefings') .where('fandomId', '==', FANDOM).get(); expect(docs.size).toBe(1); }); it('빈 목록과 권한 없는 호출을 끊는다', async () => { await expect(fnBriefingIngest.run(req('ops', { items: [] }))) .rejects.toThrowError('수신할 브리핑 항목이 없습니다.'); await expect(fnBriefingIngest.run(req('viewer', { items: [candidate()] }))) .rejects.toThrowError('권한이 없습니다.'); await expect(fnBriefingIngest.run(req(undefined, { items: [candidate()] }))) .rejects.toThrowError('로그인이 필요합니다.'); }); it('전건 거부면 저장이 없다', async () => { const result = await fnBriefingIngest.run(req('ops', { items: [candidate({ aiSummary: undefined })], })); expect(result.accepted).toBe(0); const docs = await getFirestore().collection('briefings') .where('fandomId', '==', FANDOM).get(); expect(docs.size).toBe(0); }); }); describe('승인 — fn-briefing-publish (export 직접 호출)', () => { /** 당일 초안 3 건(News 2 · SNS 1)을 수신 경로로 심는다. */ async function seedDrafts(): Promise { await fnBriefingIngest.run(req('ops', { items: [ candidate({ sourceUrl: 'https://example.com/n/1' }), candidate({ sourceUrl: 'https://example.com/n/2' }), candidate({ section: 'SNS', sourceUrl: 'https://example.com/s/1' }), ], })); } it('viewer 는 게시하지 못하고 초안이 그대로 남는다', async () => { await seedDrafts(); await expect( fnBriefingPublish.run(req('viewer', { fandomId: FANDOM, dayKey: dayKey(new Date()), })), ).rejects.toThrowError('권한이 없습니다.'); const docs = await getFirestore().collection('briefings') .where('fandomId', '==', FANDOM).get(); expect(docs.docs.every((d) => d.get('status') === 'draft')).toBe(true); }); it('로그인하지 않은 호출은 끊는다', async () => { await expect( fnBriefingPublish.run(req(undefined, { fandomId: FANDOM, dayKey: dayKey(new Date()), })), ).rejects.toThrowError('로그인이 필요합니다.'); }); it('초안이 없으면 게시할 것이 없다고 알린다', async () => { await expect( fnBriefingPublish.run(req('master', { fandomId: FANDOM, dayKey: dayKey(new Date()), })), ).rejects.toThrowError('게시할 브리핑 초안이 없습니다.'); }); it('master 는 게시하고 섹션별 건수까지 로그에 남긴다', async () => { const db = getFirestore(); const day = dayKey(new Date()); await seedDrafts(); const result = await fnBriefingPublish.run( req('master', { fandomId: FANDOM, dayKey: day }), ); expect(result.published).toBe(3); expect(result.bySection).toEqual({ News: 2, SNS: 1 }); // FCM 자격 증명이 없는 환경이라 발송은 실패한다. **게시는 되돌리지 않는다** — // 관리자가 본 결과와 사용자가 보는 화면이 갈리지 않게 한 설계 그대로다. expect(result.pushed).toBe(false); const docs = await db.collection('briefings') .where('fandomId', '==', FANDOM).get(); expect(docs.docs.every((d) => d.get('status') === 'published')).toBe(true); expect(docs.docs.every((d) => d.get('publishedBy') === 'admin-1')).toBe(true); const logs = await db.collection('adminActionLogs') .where('targetPath', '==', `briefings/${FANDOM}_${day}`).get(); expect(logs.size).toBe(1); expect(logs.docs[0]!.get('reason')).toBe('BRIEFING_PUBLISH'); expect(logs.docs[0]!.get('role')).toBe('master'); }); }); describe('당일 필터 — 앱이 읽는 질의', () => { it('게시된 당일분만 고르고 초안과 전일분은 제외한다', async () => { const db = getFirestore(); const today = dayKey(new Date()); const yesterday = dayKey(new Date(Date.now() - 24 * 60 * 60 * 1000)); await db.doc(`briefings/${FANDOM}_${today}_News_a`).set({ fandomId: FANDOM, dayKey: today, section: 'News', rank: 1, status: 'published', headline: '오늘 게시분', }); await db.doc(`briefings/${FANDOM}_${today}_News_b`).set({ fandomId: FANDOM, dayKey: today, section: 'News', rank: 2, status: 'draft', headline: '오늘 초안', }); await db.doc(`briefings/${FANDOM}_${yesterday}_News_a`).set({ fandomId: FANDOM, dayKey: yesterday, section: 'News', rank: 1, status: 'published', headline: '어제 게시분', }); const visible = await db.collection('briefings') .where('fandomId', '==', FANDOM) .where('status', '==', 'published') .where('dayKey', '==', today) .orderBy('rank') .get(); expect(visible.docs.map((d) => d.get('headline'))).toEqual(['오늘 게시분']); }); }); describe('푸시 토픽', () => { it('구분마다 토픽을 나눈다 — OFF 구분은 구독 자체가 없다', () => { expect(fandomTopic('bts', 'briefing')).toBe('fandom_bts_briefing'); expect(fandomTopic('bts', 'marketing')).toBe('fandom_bts_marketing'); }); it('FCM 이 받지 않는 문자를 걸러 낸다', () => { expect(fandomTopic('b t/s', 'briefing')).toBe('fandom_b-t-s_briefing'); }); }); describe('이클립스 사전 알림 — 시작 − 3분 (원장 C-22)', () => { /// 예약 회차 한 건을 심는다. [startInMin] 분 뒤 시작, 예고는 그 3분 전이다. async function round(id: string, startInMin: number | null, extra: Record = {}) { const ref = getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${id}`); const start = Date.now() + (startInMin ?? 0) * 60_000; await ref.set({ status: 'scheduled', ...(startInMin === null ? {} : { startAt: Timestamp.fromMillis(start), warnAt: Timestamp.fromMillis(start - 180_000), }), ...extra, }); return ref; } it('예고 시각이 지나고 시작 전이면 보내려 하고 판정 시각을 남긴다', async () => { const ref = await round('fresh', 3); const now = new Date(); // FCM 자격 증명이 없는 환경이라 발송 자체는 실패한다. 판정이 발송까지 갔다는 것이 확인 대상이다. expect(await notifyEclipseWarning(await ref.get(), now)).toBe('failed'); expect((await ref.get()).get('alertPushedAt').toMillis()).toBe(now.getTime()); }); it('예고 시각 전에는 보내지 않고 표시도 남기지 않는다', async () => { const ref = await round('early', 5); expect(await notifyEclipseWarning(await ref.get())).toBe('too-early'); expect((await ref.get()).get('alertPushedAt')).toBeUndefined(); }); it('시작이 지난 회차에는 예고를 소급 발송하지 않는다', async () => { const ref = await round('stale', -1); expect(await notifyEclipseWarning(await ref.get())).toBe('too-old'); expect((await ref.get()).get('alertPushedAt')).toBeTruthy(); }); it('시각을 모르거나 취소된 회차에는 보내지 않는다', async () => { const unknown = await round('unknown', null); expect(await notifyEclipseWarning(await unknown.get())).toBe('no-start'); expect((await unknown.get()).get('alertPushedAt')).toBeUndefined(); const cancelled = await round('cancelled', 3, { status: 'cancelled' }); expect(await notifyEclipseWarning(await cancelled.get())).toBe('already'); }); it('같은 회차에 두 번 보내지 않는다 — 1 분 주기 점검이 도배하지 않는다', async () => { const ref = await round('once', 3); await notifyEclipseWarning(await ref.get()); expect(await notifyEclipseWarning(await ref.get())).toBe('already'); }); });