import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { FieldValue, getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin, wipeUser } from './support.js'; import { fnTranslationMark, fnTranslationSettle, fnTranslationSubmit, } from '../src/account/translation.js'; import { grantPop, reconcileUser, settlePendingGrant } from '../src/ledger/ledger.js'; /** * 참여형 번역 왕복 — 등록(보류 지급) → 마킹(자격 판정) → 24시간 뒤 정산(확정/해제). * 원장 B-63: 1건 5 POP · 24시간 확정대기 · 자기 마킹 제외 · 중복 0 · 일일상한. */ const AUTHOR = 'test-trn-author'; const COPIER = 'test-trn-copier'; const MARKER = 'test-trn-marker'; const NEWBIE = 'test-trn-newbie'; const OUTSIDER = 'test-trn-outsider'; const USERS = [AUTHOR, COPIER, MARKER, NEWBIE, OUTSIDER]; const TARGET = 'questions/test-trn-q1'; const POST = 'posts/test-trn-p1'; const CAMPAIGN = 'campaigns/test-trn-c1'; const HIDDEN_POST = 'posts/test-trn-hidden'; const REVIEW_CAMPAIGN = 'campaigns/test-trn-review'; const NO_FANDOM_CAMPAIGN = 'campaigns/test-trn-nofandom'; const DAY = 24 * 3600 * 1000; type SubmitReq = Parameters[0]; type MarkReq = Parameters[0]; const submit = (uid: string, body: string, targetPath = TARGET) => fnTranslationSubmit.run({ data: { targetPath, locale: 'en', body }, auth: { uid, token: {} }, } as SubmitReq); const mark = (uid: string, entryId: string, valid = true) => fnTranslationMark.run({ data: { entryId, valid }, auth: { uid, token: {} }, } as MarkReq); const settle = () => fnTranslationSettle.run( { scheduleTime: new Date().toISOString() } as Parameters[0], ); const age = (entryId: string, hours: number) => getFirestore() .doc(`translationEntries/${entryId}`) .update({ createdAt: Timestamp.fromMillis(Date.now() - hours * 3600 * 1000) }); const wallet = async (uid: string) => (await getFirestore().doc(`users/${uid}/wallet/main`).get()).data() ?? {}; beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { const db = getFirestore(); await Promise.all(USERS.map(wipeUser)); const old = await db.collection('translationEntries') .where('targetPath', 'in', [TARGET, POST, `${POST}/comments/c1`, `${POST}/comments/hidden`, CAMPAIGN, NO_FANDOM_CAMPAIGN]).get(); for (const d of old.docs) { const marks = await d.ref.collection('markings').get(); await Promise.all(marks.docs.map((m) => m.ref.delete())); await d.ref.delete(); } const counters = await db.collection('translationDaily').get(); await Promise.all(counters.docs.filter((d) => USERS.includes(d.get('uid'))).map((d) => d.ref.delete())); const old2 = Timestamp.fromMillis(Date.now() - 2 * DAY); await Promise.all([ db.doc(TARGET).set({ title: '질문', fandomId: 'trn-f1' }), db.doc(POST).set({ body: '글', fandomId: 'trn-f1', visibility: 'visible' }), db.doc(`${POST}/comments/c1`).set({ body: '댓글', visibility: 'visible' }), db.doc(`${POST}/comments/hidden`).set({ body: '가린 댓글', visibility: 'hidden' }), db.doc(HIDDEN_POST).set({ body: '가린 글', fandomId: 'trn-f1', visibility: 'hidden' }), db.doc(CAMPAIGN).set({ title: '캠페인', fandomId: 'trn-f1', status: 'funding' }), db.doc(REVIEW_CAMPAIGN).set({ title: '검수 중', fandomId: 'trn-f1', status: 'review' }), db.doc(NO_FANDOM_CAMPAIGN).set({ title: '팬덤 없음', status: 'funding' }), db.doc(`users/${AUTHOR}`).set({ createdAt: old2, fandomId: 'trn-f1', displayName: '김실명', nickname: '호떡이' }), db.doc(`users/${COPIER}`).set({ createdAt: old2, fandomId: 'trn-f1' }), db.doc(`users/${MARKER}`).set({ createdAt: old2, fandomId: 'trn-f1' }), db.doc(`users/${NEWBIE}`).set({ createdAt: Timestamp.now(), fandomId: 'trn-f1' }), db.doc(`users/${OUTSIDER}`).set({ createdAt: old2, fandomId: 'trn-f2' }), ]); }); describe('번역 등록', () => { it('보상분은 예약 잔액으로 잡히고(G-05) 일일 카운터가 오른다', async () => { const first = await submit(AUTHOR, 'Hello fandom'); expect(first).toMatchObject({ rewardPop: 5, excludedReason: null }); const w = await wallet(AUTHOR); expect(w.activityBalance ?? 0).toBe(0); expect(w.pendingActivity).toBe(5); const today = (await getFirestore().collection('translationDaily') .where('uid', '==', AUTHOR).get()).docs[0]!; expect(today.get('count')).toBe(1); }); it('같은 대상·언어 재등록은 E-TRN-05 · 남의 문장 복사는 보상 0 · 예약 없음', async () => { await submit(AUTHOR, 'Hello fandom'); await expect(submit(AUTHOR, 'Hello again')).rejects.toMatchObject({ details: { code: 'E-TRN-05' } }); expect(await submit(COPIER, 'Hello fandom')).toMatchObject({ rewardPop: 0, excludedReason: 'duplicate' }); expect((await wallet(COPIER)).pendingActivity ?? 0).toBe(0); }); it('번역 댓글은 글 경로(rootPath)와 작성자 표시명을 함께 남긴다 — 댓글 · 캠페인 대상 포함', async () => { const onComment = await submit(AUTHOR, 'Nice comment', `${POST}/comments/c1`); const onCampaign = await submit(AUTHOR, 'Cheer up', CAMPAIGN); const db = getFirestore(); const a = await db.doc(`translationEntries/${onComment.entryId}`).get(); expect(a.get('rootPath')).toBe(POST); expect(a.get('fandomId')).toBe('trn-f1'); expect(a.get('authorName')).toBe('호떡이'); const b = await db.doc(`translationEntries/${onCampaign.entryId}`).get(); expect(b.get('rootPath')).toBe(CAMPAIGN); expect(b.get('fandomId')).toBe('trn-f1'); }); it('작성자 닉네임이 없으면 공급자 실명 대신 「팬」을 싣는다(M-7)', async () => { await getFirestore().doc(`users/${AUTHOR}`).update({ nickname: FieldValue.delete() }); const { entryId } = await submit(AUTHOR, 'Hello fandom'); const e = await getFirestore().doc(`translationEntries/${entryId}`).get(); expect(e.get('authorName')).toBe('팬'); expect(e.get('validCount')).toBe(0); expect(e.get('invalidCount')).toBe(0); }); it('숨긴 글 · 숨긴 댓글 · 공개 전 캠페인은 없는 콘텐츠와 같은 E-TRN-03 이다', async () => { for (const target of [HIDDEN_POST, `${POST}/comments/hidden`, REVIEW_CAMPAIGN, `${HIDDEN_POST}/comments/x`]) { await expect(submit(AUTHOR, 'x', target)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } }); } }); it('없는 콘텐츠와 허용 밖 경로는 같은 E-TRN-03 이다', async () => { await expect(submit(AUTHOR, 'x', 'questions/none-such')).rejects.toMatchObject({ details: { code: 'E-TRN-03' } }); await expect(submit(AUTHOR, 'x', `users/${MARKER}`)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } }); }); it('일일상한 3배(기본 30건)를 넘기면 E-TRN-02', async () => { const db = getFirestore(); const { dayKey } = await import('../src/core/time.js'); await db.doc(`translationDaily/${AUTHOR}_${dayKey(new Date())}`).set({ uid: AUTHOR, count: 30 }); await expect(submit(AUTHOR, 'Hello')).rejects.toMatchObject({ details: { code: 'E-TRN-02' } }); }); }); describe('번역 마킹', () => { it('본인 E-TRN-01 · 가입 24시간 미만 · 다른 팬덤(라운지) E-TRN-04 · 두 번째 E-TRN-05', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom', POST); await expect(mark(AUTHOR, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-01' } }); await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } }); await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } }); await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true }); await expect(mark(MARKER, entryId, false)).rejects.toMatchObject({ details: { code: 'E-TRN-05' } }); // 마킹 수는 같은 트랜잭션에서 번역 문서에 오른다 — 실패한 두 번째 마킹은 세지 않는다. const e = await getFirestore().doc(`translationEntries/${entryId}`).get(); expect(e.get('validCount')).toBe(1); expect(e.get('invalidCount')).toBe(0); }); it('무효 마킹은 invalidCount 를 올린다 · 동시에 두 번 눌러도 한 번만 센다', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom', POST); const results = await Promise.allSettled([mark(MARKER, entryId, false), mark(MARKER, entryId, false)]); expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); const e = await getFirestore().doc(`translationEntries/${entryId}`).get(); expect(e.get('invalidCount')).toBe(1); expect(e.get('validCount')).toBe(0); }); it('등록 뒤 가려진 글의 번역은 마킹하지 못한다(E-TRN-03)', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom', `${POST}/comments/c1`); await getFirestore().doc(POST).update({ visibility: 'hidden' }); await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } }); }); it('팬덤이 없는 캠페인의 번역은 아무도 마킹하지 못한다(E-TRN-04)', async () => { const { entryId } = await submit(AUTHOR, 'Cheer', NO_FANDOM_CAMPAIGN); await expect(mark(MARKER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } }); }); it('캠페인 번역은 그 팬덤 멤버만 마킹한다', async () => { const { entryId } = await submit(AUTHOR, 'Cheer', CAMPAIGN); await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } }); await expect(mark(MARKER, entryId)).resolves.toMatchObject({ valid: true }); }); it('INSIDE 번역은 다른 팬덤 회원도 마킹한다(Q-TR-04 권고 B안) · 가입 24시간은 그대로', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom'); await expect(mark(NEWBIE, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } }); await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true }); }); it('운영값을 끄면(A안) INSIDE 도 그 팬덤 멤버만 마킹한다', async () => { const policy = getFirestore().doc('config/policy'); const before = await policy.get(); await policy.set({ translationInsideAnyMember: false }, { merge: true }); try { const { entryId } = await submit(AUTHOR, 'Hello fandom'); await expect(mark(OUTSIDER, entryId)).rejects.toMatchObject({ details: { code: 'E-TRN-04' } }); } finally { if (before.exists) await policy.set(before.data()!); else await policy.delete(); } }); it('팬덤 멤버 문서가 있으면 users.fandomId 가 달라도 마킹할 수 있다', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom', POST); await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).set({ uid: OUTSIDER }); await expect(mark(OUTSIDER, entryId)).resolves.toMatchObject({ valid: true }); await getFirestore().doc(`fandoms/trn-f1/members/${OUTSIDER}`).delete(); }); }); describe('번역 정산', () => { it('유효 마킹 1건 이상이면 예약이 사용 가능으로 옮겨지고 대사가 맞는다', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom'); await mark(MARKER, entryId); await age(entryId, 25); await settle(); expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('confirmed'); const w = await wallet(AUTHOR); expect(w.activityBalance).toBe(5); expect(w.pendingActivity).toBe(0); expect((await reconcileUser(AUTHOR)).ok).toBe(true); }); it('24시간 전이거나 마킹이 모자라면 pending 을 유지한다', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom'); await settle(); await age(entryId, 25); await settle(); expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('pending'); expect((await wallet(AUTHOR)).pendingActivity).toBe(5); }); it('최대 대기(72시간)를 넘기면 예약을 해제하고 excluded 로 닫는다', async () => { const { entryId } = await submit(AUTHOR, 'Hello fandom'); await age(entryId, 73); await settle(); const entry = await getFirestore().doc(`translationEntries/${entryId}`).get(); expect(entry.get('status')).toBe('excluded'); expect(entry.get('excludedReason')).toBe('unverified'); const w = await wallet(AUTHOR); expect(w.activityBalance ?? 0).toBe(0); expect(w.pendingActivity).toBe(0); expect((await reconcileUser(AUTHOR)).ok).toBe(true); }); it('보상 0 건은 예약 없이 excluded 로 닫는다', async () => { await submit(AUTHOR, 'Hello fandom'); const { entryId } = await submit(COPIER, 'Hello fandom'); await age(entryId, 25); await settle(); expect((await getFirestore().doc(`translationEntries/${entryId}`).get()).get('status')).toBe('excluded'); expect((await wallet(COPIER)).activityBalance ?? 0).toBe(0); }); }); describe('보류 지급 정산 — settlePendingGrant', () => { const key = 'trn:ledger-unit'; it('확정은 한 번뿐이고, 확정 뒤 해제 요청은 기존 결과를 돌려준다', async () => { await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: key, pending: true }); await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'confirm' }); await settlePendingGrant({ uid: AUTHOR, idempotencyKey: key, outcome: 'release' }); const w = await wallet(AUTHOR); expect(w.activityBalance).toBe(7); expect(w.pendingActivity).toBe(0); expect((await reconcileUser(AUTHOR)).ok).toBe(true); }); it('보류가 아닌 지급은 정산할 수 없다', async () => { await grantPop({ uid: AUTHOR, popType: 'activity', amount: 7, reasonCode: 'TRANSLATION', idempotencyKey: `${key}:x` }); await expect(settlePendingGrant({ uid: AUTHOR, idempotencyKey: `${key}:x`, outcome: 'confirm' })) .rejects.toMatchObject({ details: { code: 'E-POP-02' } }); }); }); describe('캠페인 응원 게시판 댓글 번역', () => { const OPEN = 'campaigns/test-trn-c2'; const DRAFT = 'campaigns/test-trn-c3'; beforeEach(async () => { const db = getFirestore(); const old = await db.collection('translationEntries') .where('targetPath', '==', `${OPEN}/comments/ok`).get(); await Promise.all(old.docs.map((d) => d.ref.delete())); await Promise.all([ db.doc(OPEN).set({ status: 'funding', fandomId: 'trn-f1' }), db.doc(`${OPEN}/comments/ok`).set({ body: '응원', visibility: 'visible' }), db.doc(`${OPEN}/comments/hidden`).set({ body: '숨김', visibility: 'hidden' }), db.doc(DRAFT).set({ status: 'review', fandomId: 'trn-f1' }), db.doc(`${DRAFT}/comments/ok`).set({ body: '응원', visibility: 'visible' }), ]); }); it('공개 캠페인의 보이는 댓글에만 달고, 루트는 캠페인이다', async () => { const { entryId } = await submit(AUTHOR, 'Cheer', `${OPEN}/comments/ok`); const entry = await getFirestore().doc(`translationEntries/${entryId}`).get(); expect(entry.get('rootPath')).toBe(OPEN); expect(entry.get('fandomId')).toBe('trn-f1'); for (const path of [`${OPEN}/comments/hidden`, `${DRAFT}/comments/ok`, `${OPEN}/comments/none`]) { await expect(submit(AUTHOR, 'x', path)).rejects.toMatchObject({ details: { code: 'E-TRN-03' } }); } }); });