import { afterAll, beforeAll, expect, it } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { fnPlanetHistory } from '../src/fandom/history.js'; import { bootAdmin, shutdownAdmin } from './support.js'; const fandomId = 'history-test'; const uid = 'history-reader'; const call = (data: Record, user = uid) => fnPlanetHistory.run({ data, ...(user ? { auth: { uid: user, token: {} } } : {}) } as never); beforeAll(async () => { bootAdmin(); const db = getFirestore(); for (let i = 1; i <= 22; i++) { await db.doc(`fandoms/${fandomId}/stateHistory/${i}`).set({ kind: 'evolution', fromStage: 1, toStage: 2, occurredAt: Timestamp.fromMillis(i * 1000), sourceEventId: 'private-source', settings: { private: true }, }); } await db.doc(`users/${uid}/growthEvents/one`).set({ fandomIdAtPayout: fandomId, amount: 10, sourceType: 'zap', reversalOf: null, createdAt: Timestamp.fromMillis(10_000), }); }); afterAll(async () => { const db = getFirestore(); await db.recursiveDelete(db.doc(`fandoms/${fandomId}`)); await db.recursiveDelete(db.doc(`users/${uid}`)); await shutdownAdmin(); }); it('자신의 과거 기여만 확인하며 다른 사용자 ID나 내부 기록을 노출하지 않는다', async () => { const result = await call({ fandomId }); expect(result.items).toHaveLength(20); expect(result.items[0]!.participationConfirmed).toBe(true); expect(result.items.at(-1)!.participationConfirmed).toBe(false); expect(JSON.stringify(result)).not.toContain('private'); const stranger = await call({ fandomId, uid }, 'other-user'); expect(stranger.items.every((item) => item.participationConfirmed === false)).toBe(true); }); it('커서 페이지가 겹치지 않으며 인증·경로·커서 오류를 거부한다', async () => { const first = await call({ fandomId }); const second = await call({ fandomId, cursor: first.nextCursor }); expect(second.items).toHaveLength(2); expect(second.nextCursor).toBeNull(); expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(22); await expect(call({ fandomId }, '')).rejects.toThrow(); await expect(call({ fandomId: '../users/private' })).rejects.toThrow(); await expect(call({ fandomId, cursor: 'missing' })).rejects.toThrow(); }); it('수량이 잘못된 기록은 개인 참여 증거로 쓰지 않는다', async () => { await getFirestore().doc(`users/${uid}/growthEvents/one`).update({ amount: '10' }); const result = await call({ fandomId }); expect(result.items.every((item) => item.participationConfirmed === false)).toBe(true); }); it('내 활동은 호출자와 지급 당시 팬덤으로 제한하고 다른 팬덤 커서를 거부한다', async () => { const events = getFirestore().collection(`users/${uid}/growthEvents`); await events.doc('one').update({ amount: 10 }); for (const [index, sourceType] of ['quest_reward', 'company_ad_bonus', 'zap', 'funding'].entries()) { await events.doc(`activity-${index}`).set({ fandomIdAtPayout: fandomId, sourceType, amount: 10 + index, reversalOf: null, createdAt: Timestamp.fromMillis(30_000 + index), sourceEventId: 'private-ledger', }); } await events.doc('other-fandom').set({ fandomIdAtPayout: 'other-fandom', sourceType: 'zap', amount: 999, createdAt: Timestamp.fromMillis(40_000) }); const result = await call({ fandomId, scope: 'personal' }); expect(result.items).toHaveLength(5); expect(result.items[0]!.kind).toBe('funding'); expect(result.items[0]!.amount).toBe(13); expect(JSON.stringify(result)).not.toContain('private-ledger'); expect((await call({ fandomId, scope: 'personal', uid }, 'other-user')).items).toEqual([]); await expect(call({ fandomId, scope: 'personal', cursor: 'other-fandom' })).rejects.toThrow(); await expect(call({ fandomId, scope: 'all-users' })).rejects.toThrow(); });