413 lines
17 KiB
TypeScript
413 lines
17 KiB
TypeScript
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, fnBriefingReject, 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('항목별 검수 — 승인 · 반려(상세정의 §12)', () => {
|
|
const day = () => dayKey(new Date());
|
|
async function ids(): Promise<string[]> {
|
|
await fnBriefingIngest.run(req('ops', {
|
|
items: [
|
|
candidate({ sourceUrl: 'https://example.com/i/1' }),
|
|
candidate({ sourceUrl: 'https://example.com/i/2' }),
|
|
candidate({ section: 'SNS', sourceUrl: 'https://example.com/i/3', sourceKind: 'fandom' }),
|
|
],
|
|
}));
|
|
const docs = await getFirestore().collection('briefings').where('fandomId', '==', FANDOM).get();
|
|
return docs.docs.map((d) => d.id).sort();
|
|
}
|
|
|
|
it('출처 구분은 선택 필드 — 받으면 저장하고, 정해진 값이 아니면 거부한다', async () => {
|
|
expect(validateCandidate(candidate({ sourceKind: 'blog' })).ok).toBe(false);
|
|
const [, , third] = await ids();
|
|
const kinds = (await getFirestore().collection('briefings').where('fandomId', '==', FANDOM).get())
|
|
.docs.map((d) => d.get('sourceKind'));
|
|
expect(kinds.filter((k) => k === 'fandom')).toHaveLength(1);
|
|
expect(third).toBeTruthy();
|
|
});
|
|
|
|
it('고른 항목만 게시하고, 반려는 사유와 함께 닫고 다시 게시되지 않는다', async () => {
|
|
const [a, b, c] = await ids();
|
|
await fnBriefingReject.run(req('ops', { itemIds: [b], reason: '출처 불명확' }));
|
|
const result = await fnBriefingPublish.run(req('ops', { fandomId: FANDOM, dayKey: day(), itemIds: [a] }));
|
|
expect(result.published).toBe(1);
|
|
|
|
const db = getFirestore();
|
|
expect((await db.doc(`briefings/${a}`).get()).get('status')).toBe('published');
|
|
const rejected = await db.doc(`briefings/${b}`).get();
|
|
expect(rejected.get('status')).toBe('rejected');
|
|
expect(rejected.get('rejectReason')).toBe('출처 불명확');
|
|
expect((await db.doc(`briefings/${c}`).get()).get('status')).toBe('draft');
|
|
// 반려 · 게시된 항목은 다시 고를 수 없다.
|
|
await expect(fnBriefingPublish.run(req('ops', { fandomId: FANDOM, dayKey: day(), itemIds: [b] })))
|
|
.rejects.toThrowError('초안이 아닌 항목');
|
|
await expect(fnBriefingReject.run(req('ops', { itemIds: [a], reason: 'x' })))
|
|
.rejects.toThrowError('초안만 반려');
|
|
await expect(fnBriefingReject.run(req('viewer', { itemIds: [c], reason: 'x' })))
|
|
.rejects.toThrowError('권한이 없습니다.');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|