import { afterAll, beforeAll, expect, it, vi } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { cleanupCampaignComment, cleanupPost, fnCampaignCommentCount, fnCampaignCommentLikeCount, fnPostCommentCount, fnPostLikeCount, recountPost, } from '../src/inside/lounge.js'; import { bootAdmin, shutdownAdmin } from './support.js'; /** 라운지 좋아요 · 댓글 수는 하위 문서를 다시 세어 적는다 — 재전달에도 부풀지 않는다. */ const POST = 'test-lounge-post'; beforeAll(() => { bootAdmin(); }); afterAll(async () => { const db = getFirestore(); for (const id of [POST, 'test-lounge-gone', 'test-lounge-stale', 'test-lounge-del']) { await db.recursiveDelete(db.doc(`posts/${id}`)); } await db.recursiveDelete(db.doc('campaigns/test-cmp-comments')); await shutdownAdmin(); }); /** 트리거 이벤트 모양. `before`/`after` 는 문서 스냅샷 흉내만 낸다. */ function written(document: string, params: Record, data?: unknown) { return { specversion: '1.0', id: `e-${Math.random()}`, source: 'firestore', type: 'google.cloud.firestore.document.v1.written', time: new Date().toISOString(), data, params, location: 'asia-northeast3', project: 'demo-fanuzu', database: '(default)', namespace: '', document, } as never; } const snap = (fields: Record | null) => ({ exists: fields !== null, get: (k: string) => fields?.[k], }); it('좋아요 수는 몇 번을 돌아도 하위 문서 수와 같다', async () => { const db = getFirestore(); await db.doc(`posts/${POST}`).set({ likeCount: 99, commentCount: 99 }); for (const uid of ['a', 'b', 'c']) { await db.doc(`posts/${POST}/likes/${uid}`).set({ createdAt: new Date() }); } await recountPost(POST, 'likes'); await recountPost(POST, 'likes'); expect((await db.doc(`posts/${POST}`).get()).get('likeCount')).toBe(3); await db.doc(`posts/${POST}/likes/a`).delete(); // 트리거 배선 — 경로 파라미터로 글을 찾는다. await fnPostLikeCount.run(written(`posts/${POST}/likes/a`, { postId: POST, uid: 'a' })); expect((await db.doc(`posts/${POST}`).get()).get('likeCount')).toBe(2); }); it('먼저 센 값이 나중에 센 값을 덮지 않는다', async () => { const db = getFirestore(); const ref = db.doc('posts/test-lounge-stale'); // 이미 「미래」 시점에 센 값이 적혀 있다 — 지금 센 값은 그보다 오래됐다. await ref.set({ likeCount: 7, likeCountAt: Timestamp.fromMillis(Date.now() + 60_000) }); await db.doc('posts/test-lounge-stale/likes/a').set({ createdAt: new Date() }); await recountPost('test-lounge-stale', 'likes'); expect((await ref.get()).get('likeCount')).toBe(7); }); it('댓글 수는 보이는 댓글만 세고, 본문만 고친 수정은 다시 세지 않는다', async () => { const db = getFirestore(); await db.doc(`posts/${POST}/comments/c1`).set({ visibility: 'visible' }); await db.doc(`posts/${POST}/comments/c2`).set({ visibility: 'visible' }); await db.doc(`posts/${POST}/comments/c3`).set({ visibility: 'hidden' }); await recountPost(POST, 'comments'); expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(2); await db.doc(`posts/${POST}`).update({ commentCount: 42 }); const params = { postId: POST, commentId: 'c1' }; const doc = `posts/${POST}/comments/c1`; await fnPostCommentCount.run(written(doc, params, { before: snap({ visibility: 'visible', body: 'a' }), after: snap({ visibility: 'visible', body: 'b' }), })); expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(42); // 숨김 전환은 다시 센다. await db.doc(`posts/${POST}/comments/c2`).update({ visibility: 'hidden' }); await fnPostCommentCount.run(written(doc, { postId: POST, commentId: 'c2' }, { before: snap({ visibility: 'visible' }), after: snap({ visibility: 'hidden' }), })); expect((await db.doc(`posts/${POST}`).get()).get('commentCount')).toBe(1); }); it('지워진 글은 다시 만들지 않는다', async () => { const db = getFirestore(); await db.doc('posts/test-lounge-gone/likes/a').set({ createdAt: new Date() }); await recountPost('test-lounge-gone', 'likes'); expect((await db.doc('posts/test-lounge-gone').get()).exists).toBe(false); }); it('글이 지워지면 좋아요 · 댓글과 작성자의 그 글 사진을 지운다', async () => { const db = getFirestore(); await db.doc('posts/test-lounge-del/likes/a').set({ createdAt: new Date() }); await db.doc('posts/test-lounge-del/comments/c').set({ visibility: 'visible' }); const deleteFiles = vi.fn(async () => {}); await cleanupPost('test-lounge-del', 'alice', deleteFiles); expect((await db.collection('posts/test-lounge-del/likes').get()).size).toBe(0); expect((await db.collection('posts/test-lounge-del/comments').get()).size).toBe(0); expect(deleteFiles).toHaveBeenCalledWith('users/alice/lounge/test-lounge-del-'); }); it('캠페인 응원 게시판 댓글 수도 보이는 댓글만 다시 세어 캠페인 문서에 적는다', async () => { const db = getFirestore(); const cmp = 'campaigns/test-cmp-comments'; await db.doc(cmp).set({ status: 'funding', commentCount: 99 }); await db.doc(`${cmp}/comments/a`).set({ visibility: 'visible' }); await db.doc(`${cmp}/comments/b`).set({ visibility: 'hidden' }); const params = { campaignId: 'test-cmp-comments', commentId: 'a' }; await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, { before: snap(null), after: snap({ visibility: 'visible' }), })); expect((await db.doc(cmp).get()).get('commentCount')).toBe(1); // 본문만 고친 수정은 다시 세지 않는다. await db.doc(cmp).update({ commentCount: 42 }); await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, { before: snap({ visibility: 'visible', body: 'x' }), after: snap({ visibility: 'visible', body: 'y' }), })); expect((await db.doc(cmp).get()).get('commentCount')).toBe(42); // 삭제는 다시 센다. await db.doc(`${cmp}/comments/a`).delete(); await fnCampaignCommentCount.run(written(`${cmp}/comments/a`, params, { before: snap({ visibility: 'visible' }), after: snap(null), })); expect((await db.doc(cmp).get()).get('commentCount')).toBe(0); }); it('응원 게시판 댓글 좋아요 수는 댓글 문서에 다시 세어 적고 댓글 수는 건드리지 않는다', async () => { const db = getFirestore(); const cmp = 'campaigns/test-cmp-comments'; const comment = `${cmp}/comments/liked`; await db.doc(cmp).set({ status: 'funding', commentCount: 5 }); await db.doc(comment).set({ visibility: 'visible', likeCount: 99 }); for (const uid of ['a', 'b']) { await db.doc(`${comment}/likes/${uid}`).set({ createdAt: new Date() }); } const params = { campaignId: 'test-cmp-comments', commentId: 'liked', uid: 'a' }; await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, params)); await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, params)); expect((await db.doc(comment).get()).get('likeCount')).toBe(2); expect((await db.doc(cmp).get()).get('commentCount')).toBe(5); }); it('응원 게시판 댓글이 지워지면 하위 좋아요를 지우고 댓글 문서를 되살리지 않는다', async () => { const db = getFirestore(); const comment = 'campaigns/test-cmp-comments/comments/gone'; await db.doc(comment).set({ visibility: 'visible', likeCount: 2 }); for (const uid of ['a', 'b']) await db.doc(`${comment}/likes/${uid}`).set({ createdAt: new Date() }); await db.doc(comment).delete(); await cleanupCampaignComment('test-cmp-comments', 'gone'); await cleanupCampaignComment('test-cmp-comments', 'gone'); expect((await db.collection(`${comment}/likes`).get()).size).toBe(0); // 좋아요 삭제로 뒤늦게 도는 수 트리거도 없는 댓글을 만들지 않는다. await fnCampaignCommentLikeCount.run(written(`${comment}/likes/a`, { campaignId: 'test-cmp-comments', commentId: 'gone', uid: 'a', })); expect((await db.doc(comment).get()).exists).toBe(false); });