Cloud Functions 도메인 함수 — POP 원장 · 퀘스트 · 이클립스 · 행성 성장 · 캠페인 · 결제 · 알림 · 브리핑 · INSIDE · 추천 · 번역 · 서버 테스트

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent f483e8d94b
commit 316821e7d5
61 changed files with 14660 additions and 60 deletions
@@ -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');
});
});