Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user