UNPKG

@accounter/server

Version:
344 lines (294 loc) • 12.1 kB
import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Injector } from 'graphql-modules'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { ChargesProvider } from '../../charges/providers/charges.provider.js'; import { BY_SCORE_EVALUATION_CAP } from '../helpers/awaiting-match-queue.helper.js'; import { QueueMatchEvaluatorProvider } from '../providers/queue-match-evaluator.provider.js'; import { chargesAwaitingMatchQueueResolver } from '../resolvers/charges-awaiting-match-queue.resolver.js'; import type { ChargeMatchProto } from '../types.js'; const OWNER_ID = 'owner-1'; /** * Minimal enriched charge row, as returned by ChargesProvider.getChargesByFilters. * Count aggregates are int8 columns, surfaced as strings. */ function makeCharge( id: string, { transactions = 0, invoices = 0, receipts = 0, type = null, }: { transactions?: number; invoices?: number; receipts?: number; type?: string | null; } = {}, ) { return { id, owner_id: OWNER_ID, type, transactions_count: transactions ? String(transactions) : null, invoices_count: invoices ? String(invoices) : null, receipts_count: receipts ? String(receipts) : null, documents_count: invoices + receipts ? String(invoices + receipts) : null, }; } const txCharge = (id: string) => makeCharge(id, { transactions: 1 }); const docCharge = (id: string) => makeCharge(id, { invoices: 1 }); type ResolverFn = ( parent: unknown, args: Record<string, unknown>, context: { injector: Injector }, info: unknown, ) => Promise<{ baseCharges: { baseCharge: { id: string }; suggestions: ChargeMatchProto[] }[]; totalCount: number }>; const resolve = chargesAwaitingMatchQueueResolver.Query! .chargesAwaitingMatchQueue as unknown as ResolverFn; function createTestContext({ charges, suggestionsById = {}, }: { charges: ReturnType<typeof makeCharge>[]; suggestionsById?: Record<string, ChargeMatchProto[]>; }) { const getChargesByFilters = vi.fn(async () => charges); const evaluateMatchesForCharges = vi.fn(async (baseCharges: { id: string }[]) => baseCharges.map(baseCharge => ({ baseCharge, suggestions: suggestionsById[baseCharge.id] ?? [], })), ); const injector = { get: vi.fn((token: unknown) => { if (token === AdminContextProvider) { return { getVerifiedAdminContext: async () => ({ ownerId: OWNER_ID }) }; } if (token === ChargesProvider) { return { getChargesByFilters }; } if (token === QueueMatchEvaluatorProvider) { return { evaluateMatchesForCharges }; } throw new Error(`Unexpected token requested from injector`); }), } as unknown as Injector; return { injector, getChargesByFilters, evaluateMatchesForCharges }; } const baseArgs = { limit: 20, offset: 0, businessId: null, fromDate: null, toDate: null, mode: null, sortBy: 'BY_DATE', }; describe('chargesAwaitingMatchQueue resolver', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('shared filtering', () => { it('should scope the DB fetch to the verified admin owner and pass filters through', async () => { const { injector, getChargesByFilters } = createTestContext({ charges: [] }); await resolve( null, { ...baseArgs, businessId: 'business-7', fromDate: '2024-01-01', toDate: '2024-12-31', }, { injector }, null, ); expect(getChargesByFilters).toHaveBeenCalledWith( expect.objectContaining({ ownerIds: [OWNER_ID], businessIds: ['business-7'], fromDate: '2024-01-01', toDate: '2024-12-31', }), ); }); it('should include only unmatched charges (tx-only or doc-only) in the queue', async () => { const { injector } = createTestContext({ charges: [ txCharge('tx-1'), makeCharge('matched-1', { transactions: 1, receipts: 1 }), docCharge('doc-1'), makeCharge('empty-1'), ], }); const result = await resolve(null, baseArgs, { injector }, null); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-1', 'doc-1']); expect(result.totalCount).toBe(2); }); it('should exclude base-charges of types that never require a document match', async () => { const nonMatchableTypes = [ 'BANK_DEPOSIT', 'CREDITCARD_BANK', 'DIVIDEND', 'FOREIGN_SECURITIES', 'INTERNAL', 'VAT', 'PAYROLL', 'CONVERSION', 'FINANCIAL', ]; const { injector } = createTestContext({ charges: [ ...nonMatchableTypes.map((type, i) => makeCharge(`skip-${i}`, { transactions: 1, type })), txCharge('tx-1'), docCharge('doc-1'), ], }); const result = await resolve(null, baseArgs, { injector }, null); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-1', 'doc-1']); expect(result.totalCount).toBe(2); }); it('should include COMMON, BUSINESS_TRIP and untyped base-charges', async () => { const { injector } = createTestContext({ charges: [ makeCharge('common-1', { transactions: 1, type: 'COMMON' }), makeCharge('trip-1', { invoices: 1, type: 'BUSINESS_TRIP' }), txCharge('untyped-1'), ], }); const result = await resolve(null, baseArgs, { injector }, null); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([ 'common-1', 'trip-1', 'untyped-1', ]); expect(result.totalCount).toBe(3); }); it('should treat a transaction charge with invoice-only documents as unmatched', async () => { const { injector } = createTestContext({ charges: [makeCharge('tx-with-invoice', { transactions: 1, invoices: 1 })], }); const result = await resolve(null, baseArgs, { injector }, null); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-with-invoice']); }); it('should filter by DOC_BASE mode', async () => { const { injector } = createTestContext({ charges: [txCharge('tx-1'), docCharge('doc-1'), docCharge('doc-2')], }); const result = await resolve(null, { ...baseArgs, mode: 'DOC_BASE' }, { injector }, null); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['doc-1', 'doc-2']); expect(result.totalCount).toBe(2); }); it('should filter by TRANSACTION_BASE mode', async () => { const { injector } = createTestContext({ charges: [txCharge('tx-1'), docCharge('doc-1'), txCharge('tx-2')], }); const result = await resolve( null, { ...baseArgs, mode: 'TRANSACTION_BASE' }, { injector }, null, ); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-1', 'tx-2']); }); }); describe('BY_DATE path', () => { it('should evaluate scores only for the requested page (limit + offset applied before scoring)', async () => { const charges = Array.from({ length: 10 }, (_, i) => txCharge(`charge-${i}`)); const { injector, evaluateMatchesForCharges } = createTestContext({ charges }); const result = await resolve(null, { ...baseArgs, limit: 3, offset: 4 }, { injector }, null); expect(evaluateMatchesForCharges).toHaveBeenCalledTimes(1); expect( (evaluateMatchesForCharges.mock.calls[0][0] as { id: string }[]).map(c => c.id), ).toEqual(['charge-4', 'charge-5', 'charge-6']); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([ 'charge-4', 'charge-5', 'charge-6', ]); expect(result.totalCount).toBe(10); }); it('should return different rows for subsequent pages', async () => { const charges = Array.from({ length: 6 }, (_, i) => txCharge(`charge-${i}`)); const { injector } = createTestContext({ charges }); const page1 = await resolve(null, { ...baseArgs, limit: 2, offset: 0 }, { injector }, null); const page2 = await resolve(null, { ...baseArgs, limit: 2, offset: 2 }, { injector }, null); expect(page1.baseCharges.map(c => c.baseCharge.id)).toEqual(['charge-0', 'charge-1']); expect(page2.baseCharges.map(c => c.baseCharge.id)).toEqual(['charge-2', 'charge-3']); }); it('should return an empty page when offset exceeds the queue length', async () => { const { injector, evaluateMatchesForCharges } = createTestContext({ charges: [txCharge('charge-0')], }); const result = await resolve(null, { ...baseArgs, limit: 5, offset: 10 }, { injector }, null); expect(result.baseCharges).toEqual([]); expect(result.totalCount).toBe(1); expect(evaluateMatchesForCharges).toHaveBeenCalledWith([]); }); }); describe('BY_SCORE path', () => { it('should cap the evaluated window, sort by top suggestion score desc, then slice the page', async () => { const charges = Array.from({ length: BY_SCORE_EVALUATION_CAP + 50 }, (_, i) => txCharge(`charge-${i}`), ); const suggestionsById: Record<string, ChargeMatchProto[]> = { 'charge-0': [{ chargeId: 'm-0', confidenceScore: 0.5 }], 'charge-1': [{ chargeId: 'm-1', confidenceScore: 0.99 }], 'charge-2': [{ chargeId: 'm-2', confidenceScore: 0.75 }], }; const { injector, evaluateMatchesForCharges } = createTestContext({ charges, suggestionsById, }); const result = await resolve( null, { ...baseArgs, sortBy: 'BY_SCORE', limit: 3, offset: 0 }, { injector }, null, ); // Evaluated exactly the capped window, in one call expect(evaluateMatchesForCharges).toHaveBeenCalledTimes(1); expect(evaluateMatchesForCharges.mock.calls[0][0]).toHaveLength(BY_SCORE_EVALUATION_CAP); // Highest scoring first expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([ 'charge-1', 'charge-2', 'charge-0', ]); // Pagination happens over the derived (capped) list expect(result.totalCount).toBe(BY_SCORE_EVALUATION_CAP); }); it('should apply limit/offset AFTER sorting the whole evaluated window', async () => { const charges = Array.from({ length: 5 }, (_, i) => txCharge(`charge-${i}`)); const suggestionsById: Record<string, ChargeMatchProto[]> = { 'charge-0': [{ chargeId: 'm', confidenceScore: 0.1 }], 'charge-1': [{ chargeId: 'm', confidenceScore: 0.9 }], 'charge-2': [{ chargeId: 'm', confidenceScore: 0.5 }], 'charge-3': [{ chargeId: 'm', confidenceScore: 0.7 }], 'charge-4': [{ chargeId: 'm', confidenceScore: 0.3 }], }; const { injector } = createTestContext({ charges, suggestionsById }); const result = await resolve( null, { ...baseArgs, sortBy: 'BY_SCORE', limit: 2, offset: 2 }, { injector }, null, ); // Sorted order: charge-1 (0.9), charge-3 (0.7), charge-2 (0.5), charge-4 (0.3), charge-0 (0.1) expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['charge-2', 'charge-4']); expect(result.totalCount).toBe(5); }); it('should rank charges without suggestions last, preserving recency order between them', async () => { const charges = [txCharge('charge-0'), txCharge('charge-1'), txCharge('charge-2')]; const suggestionsById: Record<string, ChargeMatchProto[]> = { 'charge-1': [{ chargeId: 'm', confidenceScore: 0.4 }], }; const { injector } = createTestContext({ charges, suggestionsById }); const result = await resolve(null, { ...baseArgs, sortBy: 'BY_SCORE' }, { injector }, null); expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([ 'charge-1', 'charge-0', 'charge-2', ]); }); }); });