Files
recipe-app/backend/src/receipt-import/receipt-import.service.spec.ts
T
Nils-Johan Gynther d5f903db98
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 3m41s
Test Suite / flutter-quality (push) Successful in 2m3s
chore(import): improve error handling and add flyer integration
- Replace BadRequestException with UnauthorizedException for authentication failures in flyer-import and flyer-selection controllers
- Add bulk selection endpoint in FlyerSelectionController for creating multiple selections in one request
- Update FlyerSelectionModule to include new FlyerSelectionMatcherService and FlyerSelectionSyncController
- Extend FlyerSelectionService with createMany method for bulk operations
- Add new DTOs for bulk selection and receipt matching functionality
- Update ReceiptImportService to accept FlyerSelectionService dependency and track successful rows
- Extend SaveReceiptResponse with flyerAutoSync field for receipt-to-flyer matching results
- Add new API paths for flyer import and selection endpoints
- Update Flutter UI to include Flyer import tab and adjust tab controller length
- Add new domain models and repository methods for flyer import functionality
- Update test files to include new FlyerSelectionService dependency
- Modify .kilo plan documentation to reflect current system architecture
2026-05-18 22:51:27 +02:00

409 lines
15 KiB
TypeScript

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);
});
});
});