import { describe, it, expect, vi, beforeEach } from 'vitest'; import { supabase } from '../supabase'; vi.mock('../supabase', () => { return { supabase: { from: vi.fn(), rpc: vi.fn(), }, }; }); // Import code under test after mock definition import { triggerCampaignSettlementOnApproval } from '../settlementTrigger'; describe('triggerCampaignSettlementOnApproval', () => { beforeEach(() => { vi.clearAllMocks(); }); it('successfully triggers campaign credit issuance and distribution based on actual activities', async () => { // 1. Mock external mapping and campaign query const mockMappingSingle = vi.fn().mockResolvedValue({ data: { id: 'map-777', registry_code: 'VCS', internal_type: 'campaign', internal_id: 'camp-777', verification_level: 'submitted', campaigns: { id: 'camp-777', title: '제주 다회용 텀블러 에코 캠페인', nature_beneficiary_id: 'nature:ocean:jeju', nature_beneficiary_name: '제주 바다', target_co2_kg: 10000, }, }, error: null, }); const mockMappingEq = vi.fn().mockReturnValue({ single: mockMappingSingle, }); const mockMappingSelect = vi.fn().mockReturnValue({ eq: mockMappingEq, }); // 2. Mock activities CO2 sum (e.g. 5,500,000 grams = 5.5 tCO2e = 5.5 TON) const mockActivitySelect = vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ data: [{ co2_grams: 5500000 }], error: null, }), }), }); // 3. Mock distribution rules query (e.g. 70% Reserve, 20% SME, 10% Platform) const mockRulesSingle = vi.fn().mockResolvedValue({ data: { reserve_rate: 0.70, donation_rate: 0.20, platform_rate: 0.10, }, error: null, }); const mockRulesEq = vi.fn().mockReturnValue({ maybeSingle: mockRulesSingle, }); const mockRulesSelect = vi.fn().mockReturnValue({ eq: mockRulesEq, }); // 4. Set up supabase.from routing mock vi.mocked(supabase.from).mockImplementation((table: string) => { if (table === 'external_project_mappings') { return { select: mockMappingSelect } as any; } if (table === 'activities') { return { select: mockActivitySelect } as any; } if (table === 'distribution_rules') { return { select: mockRulesSelect } as any; } return {} as any; }); // 5. Mock append_ledger_entry RPC vi.mocked(supabase.rpc).mockResolvedValue({ data: { id: 'led-1' }, error: null, } as any); // 6. Act await expect(triggerCampaignSettlementOnApproval('map-777')).resolves.not.toThrow(); // 7. Assertions - Credit Issuance Check (5,500,000g -> 5.5 TON) expect(supabase.rpc).toHaveBeenCalledWith('append_ledger_entry', expect.objectContaining({ p_category: 'CREDIT_ISSUANCE', p_asset: 'TON', p_amount: 5.5, p_sender_id: 'VCS', p_receiver_id: 'nature:ocean:jeju', })); // Assertions - Distribution Checks // Reserve: 5.5 * 0.7 = 3.85 TON expect(supabase.rpc).toHaveBeenCalledWith('append_ledger_entry', expect.objectContaining({ p_category: 'CREDIT_RETIRE', p_asset: 'TON', p_amount: 3.85, p_sender_id: 'nature:ocean:jeju', p_receiver_id: 'nature:ocean:jeju', })); // SME: 5.5 * 0.2 = 1.1 TON expect(supabase.rpc).toHaveBeenCalledWith('append_ledger_entry', expect.objectContaining({ p_category: 'CREDIT_DONATION', p_asset: 'TON', p_amount: 1.1, p_sender_id: 'nature:ocean:jeju', p_receiver_id: 'sme:allocated', })); // Platform: 5.5 * 0.1 = 0.55 TON expect(supabase.rpc).toHaveBeenCalledWith('append_ledger_entry', expect.objectContaining({ p_category: 'FEE_DEDUCTION', p_asset: 'TON', p_amount: 0.55, p_sender_id: 'nature:ocean:jeju', p_receiver_id: 'platform:fees', })); }); it('falls back to campaign target_co2_kg when actual activities sum is 0', async () => { // 1. Mock external mapping and campaign query (target: 15,000 kg = 15 TON) const mockMappingSingle = vi.fn().mockResolvedValue({ data: { id: 'map-888', registry_code: 'VCS', internal_type: 'campaign', internal_id: 'camp-888', verification_level: 'submitted', campaigns: { id: 'camp-888', title: '제주 다회용 텀블러 에코 캠페인', nature_beneficiary_id: 'nature:ocean:jeju', nature_beneficiary_name: '제주 바다', target_co2_kg: 15000, }, }, error: null, }); const mockMappingEq = vi.fn().mockReturnValue({ single: mockMappingSingle, }); const mockMappingSelect = vi.fn().mockReturnValue({ eq: mockMappingEq, }); // Activities sum is 0 const mockActivitySelect = vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ data: [], error: null, }), }), }); // Mock distribution rules (no rules, falls back to default 70:20:10) const mockRulesSingle = vi.fn().mockResolvedValue({ data: null, error: null, }); const mockRulesEq = vi.fn().mockReturnValue({ maybeSingle: mockRulesSingle, }); const mockRulesSelect = vi.fn().mockReturnValue({ eq: mockRulesEq, }); vi.mocked(supabase.from).mockImplementation((table: string) => { if (table === 'external_project_mappings') { return { select: mockMappingSelect } as any; } if (table === 'activities') { return { select: mockActivitySelect } as any; } if (table === 'distribution_rules') { return { select: mockRulesSelect } as any; } return {} as any; }); vi.mocked(supabase.rpc).mockResolvedValue({ data: { id: 'led-2' }, error: null, } as any); // 2. Act await expect(triggerCampaignSettlementOnApproval('map-888')).resolves.not.toThrow(); // 3. Assertions - Fallback Credit Issuance (15,000 kg -> 15 TON) expect(supabase.rpc).toHaveBeenCalledWith('append_ledger_entry', expect.objectContaining({ p_category: 'CREDIT_ISSUANCE', p_asset: 'TON', p_amount: 15, p_sender_id: 'VCS', p_receiver_id: 'nature:ocean:jeju', })); // Platform default 10%: 15 * 0.1 = 1.5 TON expect(supabase.rpc).toHaveBeenCalledWith('append_ledger_entry', expect.objectContaining({ p_category: 'FEE_DEDUCTION', p_asset: 'TON', p_amount: 1.5, })); }); it('prevents double issuance if the mapping is already approved', async () => { // Mapping already approved const mockMappingSingle = vi.fn().mockResolvedValue({ data: { id: 'map-already', registry_code: 'VCS', internal_type: 'campaign', internal_id: 'camp-999', verification_level: 'approved', }, error: null, }); const mockMappingEq = vi.fn().mockReturnValue({ single: mockMappingSingle, }); const mockMappingSelect = vi.fn().mockReturnValue({ eq: mockMappingEq, }); vi.mocked(supabase.from).mockImplementation((table: string) => { if (table === 'external_project_mappings') { return { select: mockMappingSelect } as any; } return {} as any; }); // Act & Assert await expect(triggerCampaignSettlementOnApproval('map-already')).rejects.toThrow( '[triggerCampaignSettlementOnApproval] 이미 승인 및 정산 완료된 매핑입니다. (ID: map-already)' ); expect(supabase.rpc).not.toHaveBeenCalled(); }); });