알림함 항목 생성 · 알림 이동 · 브리핑 항목 승인·상세 · 푸시 토픽을 가입 팬덤 전체로
This commit is contained in:
@@ -4,7 +4,7 @@ import type { CallableRequest } from 'firebase-functions/v2/https';
|
||||
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import {
|
||||
candidateDocId, fnBriefingIngest, fnBriefingPublish, validateCandidate,
|
||||
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';
|
||||
@@ -276,6 +276,51 @@ describe('승인 — fn-briefing-publish (export 직접 호출)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest';
|
||||
import { getFirestore } from 'firebase-admin/firestore';
|
||||
|
||||
import { notifyEclipseTransition, notifyHeroDrop, notifyQuestDone } from '../src/notify/inbox.js';
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
|
||||
/** 알림함 항목 — Figma `229:11265` 표본 문구 · 명세 `518:88353` 이동 규칙의 서버 절반. */
|
||||
|
||||
const FANDOM = 'test-inbox-fandom';
|
||||
const A = 'test-inbox-a';
|
||||
const B = 'test-inbox-b';
|
||||
|
||||
async function inbox(uid: string) {
|
||||
const snap = await getFirestore().collection(`users/${uid}/notifications`).get();
|
||||
return snap.docs.map((d) => d.data());
|
||||
}
|
||||
|
||||
async function wipe() {
|
||||
const db = getFirestore();
|
||||
for (const uid of [A, B]) await db.recursiveDelete(db.doc(`users/${uid}`));
|
||||
await db.recursiveDelete(db.doc(`fandoms/${FANDOM}`));
|
||||
await db.doc('quests/test-inbox-quest').delete();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
bootAdmin();
|
||||
await wipe();
|
||||
const db = getFirestore();
|
||||
await db.doc(`fandoms/${FANDOM}`).set({ name: 'BTS' });
|
||||
for (const uid of [A, B]) await db.doc(`fandoms/${FANDOM}/members/${uid}`).set({ joinedAt: new Date() });
|
||||
await db.doc('quests/test-inbox-quest').set({ title: '만보기' });
|
||||
});
|
||||
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||
|
||||
it('이클립스 발생 · 방어는 회원 전원에게 한 건씩, 재전달해도 늘지 않는다', async () => {
|
||||
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started');
|
||||
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'scheduled' }, { status: 'active' })).toBe('started');
|
||||
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'ended', result: 'success' })).toBe('defended');
|
||||
// 실패 종료 · 상태 변화 없음은 알리지 않는다.
|
||||
expect(await notifyEclipseTransition(FANDOM, 'r2', { status: 'active' }, { status: 'ended', result: 'failed' })).toBeNull();
|
||||
expect(await notifyEclipseTransition(FANDOM, 'r1', { status: 'active' }, { status: 'active' })).toBeNull();
|
||||
|
||||
for (const uid of [A, B]) {
|
||||
const items = await inbox(uid);
|
||||
const titles = items.map((n) => n.title).sort();
|
||||
expect(titles).toEqual(['내 행성의 이클립스가 발생했어요.', '내 행성의 이클립스를 방어했어요.']);
|
||||
expect(items.every((n) => n.eventType === 'eclipse' && n.source === 'BTS' && !n.targetPath)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('영웅의 선물 발동은 회원 전원에게 발동자 닉네임으로', async () => {
|
||||
await notifyHeroDrop(FANDOM, 'd1', '팜하니짱');
|
||||
const hero = (await inbox(B)).filter((n) => n.eventType === 'item_used');
|
||||
expect(hero).toHaveLength(1);
|
||||
expect(hero[0]!.title).toBe('팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!');
|
||||
expect(hero[0]!.source).toBe('아이템');
|
||||
});
|
||||
|
||||
it('퀘스트 완료는 횟수가 오른 때만 본인에게, 상세 경로를 싣는다', async () => {
|
||||
const day = '2026-10-05';
|
||||
expect(await notifyQuestDone(A, 'test-inbox-quest', undefined, { dayKey: day, count: 1 })).toBe(true);
|
||||
// 같은 횟수 재기록(updatedAt 만 바뀜)은 알리지 않는다.
|
||||
expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 1 })).toBe(false);
|
||||
expect(await notifyQuestDone(A, 'test-inbox-quest', { dayKey: day, count: 1 }, { dayKey: day, count: 2 })).toBe(true);
|
||||
const quest = (await inbox(A)).filter((n) => n.eventType === 'quest');
|
||||
expect(quest).toHaveLength(2);
|
||||
expect(quest[0]!.title).toBe('[만보기] 퀘스트를 완료했어요!');
|
||||
expect(quest[0]!.targetPath).toBe('quests/test-inbox-quest');
|
||||
});
|
||||
Reference in New Issue
Block a user