Files
fanuzu/app/firebase/functions/test/briefing.test.ts
T

368 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> = {}): Record<string, unknown> {
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<string, number[]> {
const bySection: Record<string, number[]> = {};
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<void> {
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<string, unknown> = {}) {
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');
});
});