import { CategorySuggestion } from '../ai/ai.service'; import { FlatCategory } from '../categories/categories.service'; import { isIgnoredReceiptName, ReceiptImportService } from './receipt-import.service'; function cat(id: number, name: string, path: string): FlatCategory { return { id, name, path }; } describe('ReceiptImportService test matrix', () => { const categories: FlatCategory[] = [ cat(1, 'Bröd & Kakor', 'Bröd & Kakor'), cat(2, 'Kondis & fika', 'Bröd & Kakor > Kondis & fika'), cat(3, 'Kaffebröd', 'Bröd & Kakor > Kondis & fika > Kaffebröd'), cat(10, 'Skafferi', 'Skafferi'), cat(11, 'Pasta, ris & matgryn', 'Skafferi > Pasta, ris & matgryn'), cat(12, 'Pasta', 'Skafferi > Pasta, ris & matgryn > Pasta'), cat(20, 'Frukt & Grönt', 'Frukt & Grönt'), cat(21, 'Potatis & rotsaker', 'Frukt & Grönt > Potatis & rotsaker'), cat(22, 'Potatis', 'Frukt & Grönt > Potatis & rotsaker > Potatis'), cat(30, 'Mejeri, ost & ägg', 'Mejeri, ost & ägg'), cat(31, 'Matlagning', 'Mejeri, ost & ägg > Matlagning'), cat(32, 'Grädde', 'Mejeri, ost & ägg > Matlagning > Grädde'), cat(33, 'Ägg', 'Mejeri, ost & ägg > Ägg'), cat(40, 'Dryck', 'Dryck'), cat(41, 'Juice, fruktdryck & smoothie', 'Dryck > Juice, fruktdryck & smoothie'), cat(42, 'Kyld juice & nektar', 'Dryck > Juice, fruktdryck & smoothie > Kyld juice & nektar'), cat(50, 'Glass, godis & snacks', 'Glass, godis & snacks'), cat(51, 'Godis', 'Glass, godis & snacks > Godis'), cat(52, 'Godispåsar', 'Glass, godis & snacks > Godis > Godispåsar'), cat(53, 'Choklad', 'Glass, godis & snacks > Choklad'), cat(54, 'Chokladkakor & rullar', 'Glass, godis & snacks > Choklad > Chokladkakor & rullar'), ]; const prismaMock = { category: { findMany: jest.fn().mockResolvedValue([]) }, receiptAlias: { findMany: jest.fn().mockResolvedValue([]) }, product: { findMany: jest.fn().mockResolvedValue([]) }, unitMapping: { findMany: jest.fn().mockResolvedValue([]) }, }; const aiServiceMock = { suggestCategory: jest.fn(), }; const categoriesServiceMock = { findFlattened: jest.fn(), }; const service = new ReceiptImportService( prismaMock as any, aiServiceMock as any, categoriesServiceMock as any, { commitReceiptMatches: jest.fn() } as any, ); beforeEach(() => { jest.clearAllMocks(); prismaMock.receiptAlias.findMany.mockResolvedValue([]); prismaMock.product.findMany.mockResolvedValue([]); prismaMock.unitMapping.findMany.mockResolvedValue([]); }); describe('ignore patterns', () => { it.each([ 'Willys Plus:Bröd', 'willys plus: mjölk', 'WILLYS PLUS - ÄGG', 'Willys Plus : Ost', 'Rabatt kupong', 'Summa', ])('ignorerar "%s"', (raw: string) => { expect(isIgnoredReceiptName(raw)).toBe(true); }); it.each([ 'Mezze Maniche', 'Snickers', 'Nappar Cola 80g', 'Vispgrädde 5DL', ])('ignorerar inte "%s"', (raw: string) => { expect(isIgnoredReceiptName(raw)).toBe(false); }); }); describe('rule matrix', () => { const matrix: Array<{ raw: string; expectedPath: string }> = [ { raw: 'Mezze Maniche', expectedPath: 'Skafferi > Pasta, ris & matgryn > Pasta' }, { raw: 'Nappar Cola 80g', expectedPath: 'Glass, godis & snacks > Godis > Godispåsar' }, { raw: 'Snickers', expectedPath: 'Glass, godis & snacks > Choklad > Chokladkakor & rullar' }, { raw: 'Potatis Fast', expectedPath: 'Frukt & Grönt > Potatis & rotsaker > Potatis' }, { raw: 'Ägg 24p Inne M', expectedPath: 'Mejeri, ost & ägg > Ägg' }, { raw: 'Dryck Multivitamin', expectedPath: 'Dryck > Juice, fruktdryck & smoothie > Kyld juice & nektar' }, { raw: 'Vispgrädde 5DL', expectedPath: 'Mejeri, ost & ägg > Matlagning > Grädde' }, { raw: 'Wienerbröd', expectedPath: 'Bröd & Kakor > Kondis & fika > Kaffebröd' }, ]; it.each(matrix)('klassar "$raw" -> "$expectedPath"', ({ raw, expectedPath }: { raw: string; expectedPath: string }) => { const suggestion = (service as any).ruleBasedCategorySuggestion(raw, categories) as CategorySuggestion | null; expect(suggestion).not.toBeNull(); expect(suggestion?.path).toBe(expectedPath); }); }); describe('alias fallback och prioritet', () => { function makeContext(aliases: any[], products: any[], unitMappings: any[] = [], userId?: number) { return { userId, aliases, products, unitMappings, categories, aiEnabled: false }; } it('prioriterar user-alias före global alias för samma receiptName', async () => { const aliases = [ { receiptName: 'mjolk 1l', productId: 501, product: { id: 501, name: 'Mjolk user', canonicalName: 'Mjolk user', categoryId: 30, categoryRef: { id: 30, name: 'Mejeri' }, }, }, { receiptName: 'mjolk 1l', productId: 999, product: { id: 999, name: 'Mjolk global', canonicalName: 'Mjolk global', categoryId: 30, categoryRef: { id: 30, name: 'Mejeri' }, }, }, ]; const context = makeContext(aliases, [], [], 77); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L' }, context); expect(result.matchedProductId).toBe(501); expect(result.matchedProductName).toBe('Mjolk user'); }); it('använder global alias när user-alias saknas', async () => { const aliases = [ { receiptName: 'snickers', productId: 222, product: { id: 222, name: 'Snickers', canonicalName: 'Snickers', categoryId: 53, categoryRef: { id: 53, name: 'Choklad' }, }, }, ]; const context = makeContext(aliases, [], [], 88); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'SNICKERS' }, context); expect(result.matchedProductId).toBe(222); expect(result.matchedProductName).toBe('Snickers'); }); it('normaliserar whitespace vid alias-lookup', async () => { const aliases = [ { receiptName: 'arla mjolk 1l', productId: 700, product: { id: 700, name: 'Arla Mjolk 1l', canonicalName: 'Mjolk', categoryId: 30, categoryRef: { id: 30, name: 'Mejeri' }, }, }, ]; const context = makeContext(aliases, [], [], 42); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: ' ARLA MJOLK 1L ' }, context); expect(result.matchedProductId).toBe(700); expect(result.matchedVia).toBe('alias'); }); it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => { const products = [ { id: 700, name: 'Arla Mjolk 1l', canonicalName: 'Mjolk', categoryId: 30, categoryRef: { id: 30, name: 'Mejeri' }, }, ]; const contextNoAlias = makeContext([], products, [], 42); const first = await (service as any).matchAndEnrichReceiptItem({ rawName: 'ARLA MJOLK 1L' }, contextNoAlias); expect(first.matchedProductId).toBeUndefined(); expect(first.suggestedProductId).toBe(700); // Simulerar att användaren manuellt korrigerar och alias lärs in. const aliases = [ { receiptName: 'arla mjolk 1l', productId: 700, product: { id: 700, name: 'Arla Mjolk 1l', canonicalName: 'Mjolk', categoryId: 30, categoryRef: { id: 30, name: 'Mejeri' }, }, }, ]; const contextWithAlias = makeContext(aliases, products, [], 42); const second = await (service as any).matchAndEnrichReceiptItem({ rawName: 'ARLA MJOLK 1L' }, contextWithAlias); expect(second.matchedProductId).toBe(700); expect(second.matchedProductName).toBe('Mjolk'); expect(second.suggestedProductId).toBeUndefined(); }); it('använder inlärd enhetsmappning vid aliasträff', async () => { const aliases = [ { receiptName: 'mjolk 1l', productId: 501, product: { id: 501, name: 'Mjolk user', canonicalName: 'Mjolk user', categoryId: 30, categoryRef: { id: 30, name: 'Mejeri' }, }, }, ]; const unitMappings = [{ productId: 501, originalUnit: 'l', preferredUnit: 'st' }]; const context = makeContext(aliases, [], unitMappings, 77); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L', unit: 'L' }, context); expect(result.matchedProductId).toBe(501); expect(result.unit).toBe('st'); }); }); describe('matchedVia', () => { function makeContext(aliases: any[], products: any[], unitMappings: any[] = [], userId?: number) { return { userId, aliases, products, unitMappings, categories, aiEnabled: false }; } it('sätter matchedVia: alias vid aliasträff', async () => { const aliases = [ { receiptName: 'snickers', productId: 222, product: { id: 222, name: 'Snickers', canonicalName: 'Snickers', categoryId: null, categoryRef: null }, }, ]; const context = makeContext(aliases, [], [], 10); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'SNICKERS' }, context); expect(result.matchedVia).toBe('alias'); }); it('sätter matchedVia: wordmatch vid ordbaserad matchning', async () => { const products = [ { id: 300, name: 'Mjolk', canonicalName: 'Mjolk', categoryId: null, categoryRef: null }, ]; const context = makeContext([], products, [], 10); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L' }, context); expect(result.matchedVia).toBe('wordmatch'); }); it('sätter matchedVia: none när ingen matchning finns', async () => { const context = makeContext([], [], [], 10); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'XYZXYZ' }, context); expect(result.matchedVia).toBe('none'); }); }); describe('AI fallback och prioriteringskedja', () => { function makeContext( aliases: any[], products: any[], aiEnabled: boolean, unitMappings: any[] = [], userId?: number, ) { return { userId, aliases, products, unitMappings, categories, aiEnabled }; } it('använder AI-fallback när ingen alias/wordmatch/regelträff finns och aiEnabled=true', async () => { aiServiceMock.suggestCategory.mockResolvedValue({ categoryId: 42, categoryName: 'Kyld juice & nektar', path: 'Dryck > Juice, fruktdryck & smoothie > Kyld juice & nektar', confidence: 'low', }); const context = makeContext([], [], true, [], 10); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'XYZXYZ 123' }, context); expect(aiServiceMock.suggestCategory).toHaveBeenCalledWith('XYZXYZ 123', categories); expect(result.matchedVia).toBe('none'); expect(result.categorySuggestion?.categoryId).toBe(42); expect(result.categorySuggestion?.path).toBe('Dryck > Juice, fruktdryck & smoothie > Kyld juice & nektar'); }); it('kallar inte AI när aiEnabled=false och ingen annan kategorisering finns', async () => { const context = makeContext([], [], false, [], 10); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'XYZXYZ 123' }, context); expect(aiServiceMock.suggestCategory).not.toHaveBeenCalled(); expect(result.categorySuggestion).toBeUndefined(); expect(result.matchedVia).toBe('none'); }); it('hoppar över AI när kategori redan satts via produktmatchning', async () => { const products = [ { id: 808, name: 'Mjolk', canonicalName: 'Mjolk', categoryRef: { id: 30, name: 'Mejeri, ost & ägg' }, }, ]; const context = makeContext([], products, true, [], 10); const result = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MJOLK 1L' }, context); expect(result.matchedVia).toBe('wordmatch'); expect(result.categorySuggestion?.categoryId).toBe(30); expect(aiServiceMock.suggestCategory).not.toHaveBeenCalled(); }); it('följer prioritering: user alias -> global alias -> wordmatch -> AI', async () => { aiServiceMock.suggestCategory.mockResolvedValue({ categoryId: 51, categoryName: 'Godis', path: 'Glass, godis & snacks > Godis', confidence: 'low', }); const globalAlias = { receiptName: 'mixad vara', productId: 900, product: { id: 900, name: 'Global Produkt', canonicalName: 'Global Produkt', categoryRef: { id: 53, name: 'Choklad' }, }, }; const userAlias = { receiptName: 'mixad vara', productId: 901, product: { id: 901, name: 'User Produkt', canonicalName: 'User Produkt', categoryRef: { id: 30, name: 'Mejeri, ost & ägg' }, }, }; const wordProducts = [ { id: 777, name: 'Specialprodukt', canonicalName: 'Specialprodukt', categoryRef: null, }, ]; const userAliasContext = makeContext([userAlias, globalAlias], wordProducts, true, [], 10); const userAliasResult = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MIXAD VARA' }, userAliasContext); expect(userAliasResult.matchedVia).toBe('alias'); expect(userAliasResult.matchedProductId).toBe(901); const globalAliasContext = makeContext([globalAlias], wordProducts, true, [], 10); const globalAliasResult = await (service as any).matchAndEnrichReceiptItem({ rawName: 'MIXAD VARA' }, globalAliasContext); expect(globalAliasResult.matchedVia).toBe('alias'); expect(globalAliasResult.matchedProductId).toBe(900); const wordMatchContext = makeContext([], wordProducts, true, [], 10); const wordMatchResult = await (service as any).matchAndEnrichReceiptItem({ rawName: 'SPECIALPRODUKT 1st' }, wordMatchContext); expect(wordMatchResult.matchedVia).toBe('wordmatch'); expect(wordMatchResult.suggestedProductId).toBe(777); const aiFallbackContext = makeContext([], [], true, [], 10); const aiFallbackResult = await (service as any).matchAndEnrichReceiptItem({ rawName: 'helt okänd vara' }, aiFallbackContext); expect(aiFallbackResult.matchedVia).toBe('none'); expect(aiServiceMock.suggestCategory).toHaveBeenCalled(); expect(aiFallbackResult.categorySuggestion?.categoryId).toBe(51); }); }); });