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 { grantPop, refundPop } from '../src/ledger/ledger.js'; import { fundCampaign } from '../src/fandom/fandom.js'; import { createCampaign, retryFeeRefunds, withdrawCampaign } from '../src/campaign/create.js'; import { MAX_AUTO_REFUND_ATTEMPTS, cancelCampaign, fnCampaignAdminReview, fnCampaignReportSubmit, refundCampaign, retryPendingRefunds, settleDueCampaigns, submitCampaignReport, } from '../src/campaign/settle.js'; /** * 캠페인 개설 · 정산 · 자동 환불 · 결과보고 — 별첨 1 ⑤ 판정 기준 * 「성공/실패 분기 정확 · 자동환불 무결성(부분 실패 방치·중복 환불 0건) · 결과보고 게시 확인」. * * 기간 단축은 `endsAt` 을 과거로 옮기고 정산 코어를 직접 부르는 것으로 한다 — 10분 스케줄을 * 기다리지 않고 같은 코드가 도는지를 본다. */ const CREATOR = 'test-cmp-creator'; /** 검증 활동(Quest 완료) 표식 n건 — Campaign 생성자격 5회(A안). */ async function completions(uid: string, n: number) { for (let i = 0; i < n; i++) await getFirestore().doc(`users/${uid}/verifiedCompletions/q${i}_2026-09-0${i + 1}`).set({ questId: `q${i}` }); } const A = 'test-cmp-donor-a'; const B = 'test-cmp-donor-b'; const db = () => getFirestore(); beforeAll(() => { bootAdmin(); }); afterAll(async () => { await shutdownAdmin(); }); beforeEach(async () => { for (const uid of [CREATOR, A, B]) { await db().recursiveDelete(db().collection(`users/${uid}/notifications`)); await wipeUser(uid); } // 정산·재시도는 컬렉션 전체를 훑으므로 다른 회차가 남긴 캠페인을 지운다. await db().recursiveDelete(db().collection('campaigns')); await db().recursiveDelete(db().collection('adminActionLogs')); }); async function code(run: () => Promise): Promise { try { await run(); } catch (error) { const e = error as { details?: { code?: string }; code?: string }; return e.details?.code ?? e.code ?? String(error); } throw new Error('실패해야 하는 호출이 성공했습니다.'); } /** 모금 중 캠페인과 후원들을 만들고 마감을 지난 시각으로 옮긴다(기간 단축). */ async function fundedCampaign(id: string, target: number, gifts: Array<[string, number]>) { await db().doc(`campaigns/${id}`).set({ creatorUid: CREATOR, title: `캠페인 ${id}`, status: 'funding', targetPop: target, raisedPop: 0, donorCount: 0, refundState: 'none', endsAt: Timestamp.fromMillis(Date.now() + 60_000), }); for (const [uid, amount] of gifts) { await grantPop({ uid, popType: 'support', amount: 1_000, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:${id}:${uid}`, }); await fundCampaign({ uid, campaignId: id, clientSeq: '1', amount }); } await db().doc(`campaigns/${id}`).update({ endsAt: Timestamp.fromMillis(Date.now() - 1_000) }); } const supportOf = async (uid: string) => (await db().doc(`users/${uid}/wallet/main`).get()).get('supportBalance') as number; const refundRows = async (uid: string) => (await db() .collection(`users/${uid}/popTransactions`) .where('reasonCode', '==', 'CAMPAIGN_REFUND').get()).size; const feeRefundRows = async (uid: string) => (await db() .collection(`users/${uid}/popTransactions`) .where('reasonCode', '==', 'CAMPAIGN_FEE_REFUND').get()).size; const notes = async (uid: string) => (await db().collection(`users/${uid}/notifications`).get()).docs.map((d) => d.id); describe('개설 — fn-campaign-create', () => { beforeEach(async () => { await db().doc(`users/${CREATOR}`).set({ displayName: '김실명', nickname: '개설자', fandomId: 'f-cmp', status: 'active', createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000), }); await completions(CREATOR, 5); }); const draft = { uid: CREATOR, title: '지하철 광고', body: '5주년', targetPop: 500, days: 7 }; it('등록비 100 POP(일반)을 빼고 review 로 올린다 · 같은 요청 재시도는 한 건', async () => { await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee' }); const first = await createCampaign({ ...draft, clientSeq: 's1' }); const again = await createCampaign({ ...draft, clientSeq: 's1' }); expect(again.campaignId).toBe(first.campaignId); const doc = await db().doc(`campaigns/${first.campaignId}`).get(); expect(doc.get('status')).toBe('review'); expect(doc.get('fandomId')).toBe('f-cmp'); expect(doc.get('targetPop')).toBe(500); // 개설자 이름은 공개 닉네임 — 공급자 실명(displayName)을 싣지 않는다(M-7). expect(doc.get('creatorName')).toBe('개설자'); expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(50); const fees = await db().collection(`users/${CREATOR}/popTransactions`) .where('reasonCode', '==', 'CAMPAIGN_FEE').get(); expect(fees.size).toBe(1); // 진행 중 1건 보유 시 2건째 차단(B-56). expect(await code(() => createCampaign({ ...draft, clientSeq: 's2' }))).toBe('E-PERM-01'); // 운영 승인으로 모금이 열린다. await fnCampaignAdminReview.run({ data: { campaignId: first.campaignId, requestId: 'r1', approve: true, memo: 'ok' }, auth: { uid: 'ops-user', token: { role: 'ops' } }, } as never); expect((await db().doc(`campaigns/${first.campaignId}`).get()).get('status')).toBe('funding'); }); it('검증 활동(Quest 완료) 5회 미만은 E-CMP-04 · 남은 횟수를 싣는다(Campaign 개설자격 A안)', async () => { await grantPop({ uid: CREATOR, popType: 'activity', amount: 150, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:elig' }); await db().recursiveDelete(db().collection(`users/${CREATOR}/verifiedCompletions`)); await completions(CREATOR, 3); const err = await createCampaign({ ...draft, clientSeq: 'e1' }).then(() => null, (e: unknown) => e); expect((err as { details?: Record }).details).toEqual({ code: 'E-CMP-04', done: 3, need: 5 }); expect((await db().collection('campaigns').get()).size).toBe(0); // 기준 횟수는 운영값이다. await db().doc('config/campaign').set({ minVerifiedQuests: 3 }, { merge: true }); try { expect((await createCampaign({ ...draft, clientSeq: 'e2' })).status).toBe('review'); } finally { await db().doc('config/campaign').update({ minVerifiedQuests: FieldValue.delete() }); } }); it('잔액 부족 · 가입 7일 미만은 거부하고 캠페인을 남기지 않는다', async () => { expect(await code(() => createCampaign({ ...draft, clientSeq: 'poor' }))).toBe('E-POP-01'); await db().doc(`users/${CREATOR}`).update({ createdAt: Timestamp.now() }); expect(await code(() => createCampaign({ ...draft, clientSeq: 'new' }))).toBe('E-PERM-01'); expect((await db().collection('campaigns').get()).size).toBe(0); }); }); describe('등록비 분할 · 반환 (B-57 · B-58)', () => { beforeEach(async () => { await db().doc(`users/${CREATOR}`).set({ displayName: '개설자', fandomId: 'f-cmp', status: 'active', createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000), }); await completions(CREATOR, 5); await grantPop({ uid: CREATOR, popType: 'activity', amount: 40, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:fee:a' }); await grantPop({ uid: CREATOR, popType: 'support', amount: 500, reasonCode: 'IAP_PURCHASE', idempotencyKey: 'g:fee:s' }); }); const draft = { uid: CREATOR, title: '광고', body: '본문', targetPop: 500, days: 7 }; const walletOf = async () => { const w = await db().doc(`users/${CREATOR}/wallet/main`).get(); return [w.get('activityBalance'), w.get('supportBalance')]; }; const review = (campaignId: string, requestId: string, approve: boolean) => fnCampaignAdminReview.run({ data: { campaignId, requestId, approve, memo: 'm' }, auth: { uid: 'ops-user', token: { role: 'ops' } }, } as never); it('일반 40 + 기여 60 으로 나눠 내고, 반려되면 각 타입으로 한 번만 돌려받는다', async () => { const { campaignId } = await createCampaign({ ...draft, clientSeq: 'split' }); expect(await walletOf()).toEqual([0, 440]); const fee = await db().doc(`campaigns/${campaignId}`).get(); expect((fee.get('feeTxIds') as string[]).length).toBe(2); await review(campaignId, 'r-reject', false); await review(campaignId, 'r-reject', false); const doc = await db().doc(`campaigns/${campaignId}`).get(); expect(doc.get('status')).toBe('rejected'); expect(doc.get('feeRefund')).toBe('done'); expect(await walletOf()).toEqual([40, 500]); expect(await feeRefundRows(CREATOR)).toBe(2); expect(await refundRows(CREATOR)).toBe(0); }); it('반려 전이 뒤 반환 전에 끊겨도 — 새 요청 ID 의 재시도와 스케줄이 마저 돌려준다', async () => { const { campaignId } = await createCampaign({ ...draft, clientSeq: 'crash' }); // 트랜잭션은 섰고 반환 호출 전에 죽은 상태. await db().doc(`campaigns/${campaignId}`).update({ status: 'rejected', feeRefund: 'due' }); expect(await code(() => review(campaignId, 'r-new', false))).toBe('E-CMP-03'); expect(await walletOf()).toEqual([40, 500]); expect((await db().doc(`campaigns/${campaignId}`).get()).get('feeRefund')).toBe('done'); }); it('스케줄(retryFeeRefunds)이 남은 반환을 한 번만 돌린다 · 옛 feeTxId 한 건도', async () => { const { campaignId } = await createCampaign({ ...draft, clientSeq: 'sweep' }); const ref = db().doc(`campaigns/${campaignId}`); const [general] = (await ref.get()).get('feeTxIds') as string[]; // 분할 전 데이터 모양 — 단일 feeTxId(일반 몫만 남긴다). await ref.update({ status: 'rejected', feeRefund: 'due', feeTxIds: FieldValue.delete(), feeTxId: general }); expect(await retryFeeRefunds()).toBe(1); expect(await retryFeeRefunds()).toBe(0); expect(await walletOf()).toEqual([40, 440]); expect(await feeRefundRows(CREATOR)).toBe(1); }); it('승인과 철회가 겹치면 한쪽 판정만 선다', async () => { const { campaignId } = await createCampaign({ ...draft, clientSeq: 'race' }); const [approved] = await Promise.allSettled([ review(campaignId, 'r-race', true), withdrawCampaign(CREATOR, campaignId), ]); const doc = await db().doc(`campaigns/${campaignId}`).get(); if (approved.status === 'fulfilled') { // 승인이 먼저 — 철회는 공개 후 첫 후원 전 철회라 비환불. expect(['funding', 'withdrawn']).toContain(doc.get('status')); expect(await feeRefundRows(CREATOR)).toBe(0); } else { expect(doc.get('status')).toBe('withdrawn'); expect(await feeRefundRows(CREATOR)).toBe(2); expect((await db().collection(`campaigns/${campaignId}/reviews`).get()).size).toBe(0); } }); it('공개 전 철회는 등록비를 돌려주고 재시도해도 한 번뿐이다', async () => { const { campaignId } = await createCampaign({ ...draft, clientSeq: 'wd' }); expect((await withdrawCampaign(CREATOR, campaignId)).status).toBe('withdrawn'); await withdrawCampaign(CREATOR, campaignId); expect(await walletOf()).toEqual([40, 500]); expect(await feeRefundRows(CREATOR)).toBe(2); // 철회한 캠페인은 진행 중 1건에 세지 않는다. await createCampaign({ ...draft, clientSeq: 'wd2' }); }); it('공개 후 첫 후원 전 철회는 되지만 등록비는 비환불 · 첫 후원 뒤에는 E-PERM-01', async () => { const { campaignId } = await createCampaign({ ...draft, clientSeq: 'pub' }); await review(campaignId, 'r-ok', true); expect(await code(() => withdrawCampaign('someone-else', campaignId))).toBe('E-PERM-01'); await withdrawCampaign(CREATOR, campaignId); expect((await db().doc(`campaigns/${campaignId}`).get()).get('status')).toBe('withdrawn'); expect(await walletOf()).toEqual([0, 440]); expect(await feeRefundRows(CREATOR)).toBe(0); await fundedCampaign('donated', 1_000, [[A, 10]]); await db().doc('campaigns/donated').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) }); expect(await code(() => withdrawCampaign(CREATOR, 'donated'))).toBe('E-PERM-01'); }); }); describe('회사귀책 취소 — fn-campaign-admin-cancel (B-58)', () => { it('등록비와 후원 전액을 한 번만 돌려주고 개설자 · 후원자에게 알린다', async () => { await db().doc(`users/${CREATOR}`).set({ displayName: '개설자', fandomId: 'f-cmp', status: 'active', createdAt: Timestamp.fromMillis(Date.now() - 30 * 86_400_000), }); await completions(CREATOR, 5); await grantPop({ uid: CREATOR, popType: 'activity', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: 'g:cancel' }); const { campaignId } = await createCampaign({ uid: CREATOR, title: '광고', body: '본문', targetPop: 1_000, days: 7, clientSeq: 'cancel', }); await fnCampaignAdminReview.run({ data: { campaignId, requestId: 'ok', approve: true, memo: 'm' }, auth: { uid: 'ops-user', token: { role: 'ops' } }, } as never); for (const [uid, amount] of [[A, 30], [B, 50]] as const) { await grantPop({ uid, popType: 'support', amount: 100, reasonCode: 'ADMIN_GRANT', idempotencyKey: `seed:cancel:${uid}` }); await fundCampaign({ uid, campaignId, clientSeq: '1', amount }); } const first = await cancelCampaign(campaignId, 'ops-user', '운영 오류'); expect(first.refund.refunded).toBe(2); await cancelCampaign(campaignId, 'ops-user', '운영 오류'); const doc = await db().doc(`campaigns/${campaignId}`).get(); expect(doc.get('status')).toBe('cancelled'); expect(doc.get('refundState')).toBe('done'); expect(doc.get('feeRefund')).toBe('done'); expect(await supportOf(A)).toBe(100); expect(await supportOf(B)).toBe(100); expect(await refundRows(A)).toBe(1); expect(await refundRows(B)).toBe(1); expect(await feeRefundRows(CREATOR)).toBe(1); expect((await db().doc(`users/${CREATOR}/wallet/main`).get()).get('activityBalance')).toBe(100); expect(await notes(CREATOR)).toEqual([`campaignCancel_${campaignId}`]); expect((await notes(A)).length).toBe(1); // 환불 알림 링크가 취소 문서를 열도록 규칙이 보는 후원자 표식(backers/{uid}). const backers = await db().collection(`campaigns/${campaignId}/backers`).get(); expect(backers.docs.map((d) => d.id).sort()).toEqual([A, B].sort()); // 성공 · 실패로 끝난 캠페인은 취소 대상이 아니다. await db().doc('campaigns/done').set({ creatorUid: CREATOR, status: 'success' }); expect(await code(() => cancelCampaign('done', 'ops-user', 'x'))).toBe('E-CMP-03'); }); }); it('후원 문서에 공개 닉네임(donorName)을 싣는다 — 실시간 랭킹 이름(M-7), 닉네임 없으면 「팬」', async () => { await db().doc(`users/${A}`).set({ nickname: '후원왕', displayName: '김실명' }, { merge: true }); await fundedCampaign('cmp-names', 1_000, [[A, 10], [B, 10]]); const rows = (await db().collection('campaigns/cmp-names/donations').get()).docs.map((d) => d.data()); expect(rows.find((r) => r.uid === A)?.donorName).toBe('후원왕'); expect(rows.find((r) => r.uid === B)?.donorName).toBe('팬'); await db().recursiveDelete(db().doc('campaigns/cmp-names')); }); describe('정산 분기', () => { it('목표(targetPop) 달성은 success, 미달은 fail 이다', async () => { await fundedCampaign('cmp-ok', 500, [[A, 300], [B, 200]]); await fundedCampaign('cmp-short', 1_000, [[A, 100]]); expect(await settleDueCampaigns()).toBe(2); expect(await settleDueCampaigns()).toBe(0); // 두 번 판정하지 않는다. const ok = await db().doc('campaigns/cmp-ok').get(); const short = await db().doc('campaigns/cmp-short').get(); expect(ok.get('status')).toBe('success'); expect(short.get('status')).toBe('fail'); expect(short.get('refundState')).toBe('pending'); expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000); }); it('「응원 중」(donorCount)은 고유 후원자 수다 — 같은 사람의 두 번째 후원은 늘리지 않는다', async () => { await fundedCampaign('cmp-donors', 1_000, [[A, 100], [B, 50]]); await db().doc('campaigns/cmp-donors').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) }); await fundCampaign({ uid: A, campaignId: 'cmp-donors', clientSeq: '2', amount: 30 }); const snap = await db().doc('campaigns/cmp-donors').get(); expect(snap.get('donorCount')).toBe(2); expect(snap.get('raisedPop')).toBe(180); }); it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => { await fundedCampaign('cmp-late', 1_000, [[A, 100]]); expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 }))) .toBe('E-CMP-03'); expect((await db().doc('campaigns/cmp-late').get()).get('raisedPop')).toBe(100); }); }); describe('자동 환불', () => { it('실패 판정 뒤 관리자 호출 없이 전건을 돌려주고 알린다 · 재실행해도 중복 0', async () => { await fundedCampaign('cmp-fail', 1_000, [[A, 300], [B, 200]]); await settleDueCampaigns(); await retryPendingRefunds(); const campaign = await db().doc('campaigns/cmp-fail').get(); expect(campaign.get('refundState')).toBe('done'); expect(campaign.get('refundedCount')).toBe(2); expect(campaign.get('lockedUntil')).toBeUndefined(); expect(await supportOf(A)).toBe(1_000); expect(await supportOf(B)).toBe(1_000); const donation = (await db().collection('campaigns/cmp-fail/donations').where('uid', '==', A).get()).docs[0]!; expect(await notes(A)).toEqual([`campaignRefund_${donation.get('refundTxId')}`]); expect(await retryPendingRefunds()).toBe(0); await refundCampaign('cmp-fail', { manual: true }); expect(await refundRows(A)).toBe(1); expect(await notes(A)).toHaveLength(1); }); it('스케줄과 Admin 배치가 겹쳐도 후원 한 건당 한 번만 돌려준다', async () => { await fundedCampaign('cmp-race', 1_000, [[A, 300], [B, 200]]); await settleDueCampaigns(); const [auto, manual] = await Promise.all([ retryPendingRefunds(), refundCampaign('cmp-race', { manual: true }), ]); expect(auto + (manual.skipped ? 0 : 1)).toBeGreaterThanOrEqual(1); expect(await refundRows(A)).toBe(1); expect(await refundRows(B)).toBe(1); expect(await supportOf(A)).toBe(1_000); expect((await db().doc('campaigns/cmp-race').get()).get('refundState')).toBe('done'); }); it('잠금이 살아 있으면 건너뛴다', async () => { await fundedCampaign('cmp-lock', 1_000, [[A, 300]]); await settleDueCampaigns(); await db().doc('campaigns/cmp-lock').update({ lockedUntil: Timestamp.fromMillis(Date.now() + 60_000) }); expect((await refundCampaign('cmp-lock', { manual: true })).skipped).toBe('locked'); expect(await refundRows(A)).toBe(0); }); it('다른 경로로 이미 돌려준 원거래는 환불 끝으로 보고, 알림이 빠졌으면 다시 보낸다', async () => { await fundedCampaign('cmp-dup', 1_000, [[A, 300]]); await settleDueCampaigns(); const donation = (await db().collection('campaigns/cmp-dup/donations').get()).docs[0]!; await refundPop({ uid: A, originalTxId: donation.get('txId'), idempotencyKey: 'manual-other-key', reasonCode: 'CAMPAIGN_REFUND', }); await retryPendingRefunds(); expect((await db().doc('campaigns/cmp-dup').get()).get('refundState')).toBe('done'); expect(await refundRows(A)).toBe(1); expect(await supportOf(A)).toBe(1_000); expect(await notes(A)).toHaveLength(1); // 환불 표시는 남고 알림 전에 죽은 경우 — 알림만 다시 간다. await db().recursiveDelete(db().collection(`users/${A}/notifications`)); await donation.ref.update({ refundNotifiedAt: null }); await db().doc('campaigns/cmp-dup').update({ refundState: 'partial' }); await retryPendingRefunds(); expect(await notes(A)).toHaveLength(1); expect(await refundRows(A)).toBe(1); }); it('부분 실패는 partial 로 남아 자동 재시도하고, 상한에서 Admin 큐로 간다', async () => { await fundedCampaign('cmp-partial', 1_000, [[A, 300]]); await db().doc('campaigns/cmp-partial/donations/broken').set({ uid: B, amount: 50 }); await settleDueCampaigns(); await retryPendingRefunds(); let campaign = await db().doc('campaigns/cmp-partial').get(); expect(campaign.get('refundState')).toBe('partial'); expect(campaign.get('refundFailedCount')).toBe(1); expect(campaign.get('refundAttempts')).toBe(1); expect(await supportOf(A)).toBe(1_000); expect((await db().collection('adminActionLogs').get()).size).toBe(0); for (let i = 1; i < MAX_AUTO_REFUND_ATTEMPTS; i++) await retryPendingRefunds(); campaign = await db().doc('campaigns/cmp-partial').get(); expect(campaign.get('refundAttempts')).toBe(MAX_AUTO_REFUND_ATTEMPTS); expect(campaign.get('refundQueuedAt')).toBeTruthy(); const queue = await db().collection('adminActionLogs').get(); expect(queue.size).toBe(1); expect(queue.docs[0]?.get('targetPath')).toBe('campaigns/cmp-partial'); expect(await retryPendingRefunds()).toBe(0); expect(await refundRows(A)).toBe(1); }); it('옛 정산의 failed 는 targetPop 으로 다시 판정한다 — 목표를 채웠으면 돌려주지 않는다', async () => { // 예전 정산은 goalPop 만 읽어 목표를 채운 캠페인도 failed 로 적었다(refundAttempts 없음). await fundedCampaign('cmp-legacy-met', 500, [[A, 500]]); await fundedCampaign('cmp-legacy-short', 1_000, [[B, 200]]); for (const id of ['cmp-legacy-met', 'cmp-legacy-short']) { await db().doc(`campaigns/${id}`).update({ status: 'failed', refundState: 'pending' }); } await retryPendingRefunds(); const met = await db().doc('campaigns/cmp-legacy-met').get(); expect(met.get('status')).toBe('failed'); expect(met.get('refundState')).toBe('pending'); expect(await refundRows(A)).toBe(0); expect(await supportOf(A)).toBe(500); expect((await refundCampaign('cmp-legacy-met', { manual: true })).skipped).toBe('legacy-goal-met'); const short = await db().doc('campaigns/cmp-legacy-short').get(); expect(short.get('status')).toBe('fail'); expect(short.get('refundState')).toBe('done'); expect(await supportOf(B)).toBe(1_000); }); }); describe('결과보고 — 운영 전용 (B-35)', () => { const file = (id: string, name: string) => `campaignReports/${id}/${name}`; const uploaded = async () => true; const report = (id: string) => ({ uid: 'ops-user', campaignId: id, body: '광고 집행 완료', photoPaths: [file(id, 'p1.png')], receiptPaths: [file(id, 'r1.png')], budget: [{ label: '광고 집행', amount: 450 }, { label: '제작비', amount: 50 }], }); it('성공 캠페인에 올리면 report 로 바뀌고 후원자에게 알린다 · 재호출은 중복이고 빠진 알림만 보낸다', async () => { await fundedCampaign('cmp-rep', 500, [[A, 500]]); await settleDueCampaigns(); expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: false }); expect((await db().doc('campaigns/cmp-rep').get()).get('status')).toBe('report'); const doc = await db().doc('campaigns/cmp-rep/reports/main').get(); expect(doc.get('receiptPaths')).toEqual(['campaignReports/cmp-rep/r1.png']); expect(doc.get('budgetTotal')).toBe(500); expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']); // 게시 뒤 알림 전에 죽은 경우. await db().recursiveDelete(db().collection(`users/${A}/notifications`)); await doc.ref.update({ notifiedAt: null }); expect(await submitCampaignReport(report('cmp-rep'), uploaded)).toEqual({ duplicate: true }); expect(await notes(A)).toEqual(['campaignReport_cmp-rep_main']); }); it('운영이 아니면 거부한다(개설자 포함)', async () => { await fundedCampaign('cmp-rep-role', 500, [[A, 500]]); await settleDueCampaigns(); expect(await code(() => fnCampaignReportSubmit.run({ data: { ...report('cmp-rep-role') }, auth: { uid: CREATOR, token: {} }, } as never))).toBe('E-PERM-01'); }); it('실패 캠페인 · 경로 우회 · URL · 올라가지 않은 파일 · 빈 영수증·예산은 거부한다', async () => { await fundedCampaign('cmp-rep2', 500, [[A, 500]]); await fundedCampaign('cmp-rep-fail', 1_000, [[B, 100]]); await settleDueCampaigns(); const r = report('cmp-rep2'); expect(await code(() => submitCampaignReport(report('cmp-rep-fail'), uploaded))).toBe('E-CMP-03'); for (const bad of [ 'campaignReports/cmp-rep2/../other/p.png', 'campaignReports/cmp-rep2/sub/p.png', 'campaignReports/other/p.png', 'users/ops-user/campaignReports/cmp-rep2/p.png', 'https://firebasestorage.googleapis.com/v0/b/x/o/campaignReports%2Fcmp-rep2%2Fp.png', 'campaignReports/cmp-rep2/..', ]) { expect(await code(() => submitCampaignReport({ ...r, photoPaths: [bad] }, uploaded))).toBe('invalid-argument'); } expect(await code(() => submitCampaignReport(r, async () => false))).toBe('invalid-argument'); expect(await code(() => submitCampaignReport({ ...r, receiptPaths: [] }, uploaded))).toBe('invalid-argument'); expect(await code(() => submitCampaignReport({ ...r, budget: [] }, uploaded))).toBe('invalid-argument'); expect(await code(() => submitCampaignReport( { ...r, budget: [{ label: '광고', amount: -1 }] }, uploaded, ))).toBe('invalid-argument'); expect((await db().doc('campaigns/cmp-rep2').get()).get('status')).toBe('success'); }); });