Files
fanuzu/app/firebase/functions/test/translation.test.ts

299 lines
15 KiB
TypeScript

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<typeof fnTranslationSubmit.run>[0];
type MarkReq = Parameters<typeof fnTranslationMark.run>[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<typeof fnTranslationSettle.run>[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' } });
}
});
});