From 0784c1a0320ab91fcdb5576aa382f74a02469473 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 12 May 2026 20:56:13 +0200 Subject: [PATCH] feat: add tests for QuickImportService and ReceiptImportService parse flow --- .gitea/workflows/test.yml | 29 +++++ .../quick-import/quick-import.service.spec.ts | 115 +++++++++++++++++ .../receipt-import.parse-flow.spec.ts | 117 ++++++++++++++++++ .../receipt-import.service.spec.ts | 117 ++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 backend/src/quick-import/quick-import.service.spec.ts create mode 100644 backend/src/receipt-import/receipt-import.parse-flow.spec.ts diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index ea6b9ebb..4312298a 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -44,10 +44,39 @@ jobs: exit 1 fi + - name: Run receipt-import focused tests (PR quick) + working-directory: ./backend + run: npx jest src/receipt-import/receipt-import.service.spec.ts src/receipt-import/receipt-import.parse-flow.spec.ts src/receipt-import/receipt-import.save.spec.ts --no-coverage + - name: Build NestJS app working-directory: ./backend run: npm run build + quick-import-pr-quick: + if: gitea.event_name == 'pull_request' + runs-on: backend-node24 + + strategy: + matrix: + node-version: [24.15.0] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies (backend) + working-directory: ./backend + run: npm ci + + - name: Run quick-import focused tests (PR quick) + working-directory: ./backend + run: npx jest src/quick-import/quick-import.service.spec.ts --no-coverage + backend-full: if: gitea.event_name == 'push' runs-on: backend-node24 diff --git a/backend/src/quick-import/quick-import.service.spec.ts b/backend/src/quick-import/quick-import.service.spec.ts new file mode 100644 index 00000000..36aecbe5 --- /dev/null +++ b/backend/src/quick-import/quick-import.service.spec.ts @@ -0,0 +1,115 @@ +import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; +import { QuickImportService } from './quick-import.service'; + +jest.mock('../common/utils/download-image', () => ({ + downloadAndOptimizeImage: jest.fn(), +})); + +const { downloadAndOptimizeImage } = jest.requireMock('../common/utils/download-image') as { + downloadAndOptimizeImage: jest.Mock; +}; + +describe('QuickImportService flow', () => { + let service: QuickImportService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new QuickImportService(); + }); + + it('importFromInput: delegerar till importer och laddar ner extern bild lokalt', async () => { + (global as any).fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + markdown: '# Lasagne', + source: 'other', + imageUrl: 'https://cdn.example.com/lasagne.jpg', + }), + }); + + downloadAndOptimizeImage.mockResolvedValue('/app/recipe-images/lasagne.jpg'); + + const result = await service.importFromInput('https://www.ica.se/recept/lasagne'); + + expect((global as any).fetch).toHaveBeenCalledTimes(1); + expect((global as any).fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/quick-import'), + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + expect(downloadAndOptimizeImage).toHaveBeenCalledWith( + 'https://cdn.example.com/lasagne.jpg', + expect.any(String), + ); + expect(result.imageUrl).toBe('/app/recipe-images/lasagne.jpg'); + }); + + it('importFromUpload: skickar form-data och behåller imageUrl när den redan är lokal', async () => { + (global as any).fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + markdown: '# Köttfärssås', + source: 'image', + imageUrl: '/app/recipe-images/local.jpg', + }), + }); + + const file = { + buffer: Buffer.from('img'), + mimetype: 'image/jpeg', + originalname: 'receipt.jpg', + } as any; + + const result = await service.importFromUpload(file); + + expect((global as any).fetch).toHaveBeenCalledTimes(1); + expect(downloadAndOptimizeImage).not.toHaveBeenCalled(); + expect(result.imageUrl).toBe('/app/recipe-images/local.jpg'); + }); + + it('importFromInput: kastar BadRequestException vid tom input', async () => { + await expect(service.importFromInput(' ')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('importFromInput: mappar importer 4xx till BadRequestException', async () => { + (global as any).fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ message: 'Ogiltig URL' }), + }); + + await expect(service.importFromInput('hej')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('importFromUpload: mappar nätverksfel till ServiceUnavailableException', async () => { + (global as any).fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED')); + + const file = { + buffer: Buffer.from('img'), + mimetype: 'image/jpeg', + originalname: 'receipt.jpg', + } as any; + + await expect(service.importFromUpload(file)).rejects.toBeInstanceOf(ServiceUnavailableException); + }); + + it('lägger imageWarning när bildnedladdning misslyckas', async () => { + (global as any).fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + markdown: '# Recept', + source: 'other', + imageUrl: 'https://cdn.example.com/recept.jpg', + }), + }); + + downloadAndOptimizeImage.mockRejectedValue(new Error('timeout')); + + const result = await service.importFromInput('https://example.com/recept'); + + expect(result.imageUrl).toBe('https://cdn.example.com/recept.jpg'); + expect(result.imageWarning).toContain('Receptbild kunde inte laddas ner lokalt'); + }); +}); diff --git a/backend/src/receipt-import/receipt-import.parse-flow.spec.ts b/backend/src/receipt-import/receipt-import.parse-flow.spec.ts new file mode 100644 index 00000000..36a67ec8 --- /dev/null +++ b/backend/src/receipt-import/receipt-import.parse-flow.spec.ts @@ -0,0 +1,117 @@ +import { ReceiptImportService } from './receipt-import.service'; +import { FlatCategory } from '../categories/categories.service'; + +function cat(id: number, name: string, path: string): FlatCategory { + return { id, name, path }; +} + +describe('ReceiptImportService parseReceipt flow', () => { + const categories: FlatCategory[] = [ + cat(30, 'Mejeri, ost & ägg', 'Mejeri, ost & ägg'), + cat(53, 'Choklad', 'Glass, godis & snacks > Choklad'), + cat(51, 'Godis', 'Glass, godis & snacks > Godis'), + ]; + + const prismaMock = { + receiptAlias: { findMany: jest.fn() }, + product: { findMany: jest.fn() }, + unitMapping: { findMany: jest.fn() }, + user: { findUnique: jest.fn() }, + }; + + const aiServiceMock = { + suggestCategory: jest.fn(), + }; + + const categoriesServiceMock = { + findFlattened: jest.fn(), + }; + + const service = new ReceiptImportService( + prismaMock as any, + aiServiceMock as any, + categoriesServiceMock as any, + ); + + beforeEach(() => { + jest.clearAllMocks(); + + categoriesServiceMock.findFlattened.mockResolvedValue(categories); + prismaMock.unitMapping.findMany.mockResolvedValue([]); + prismaMock.user.findUnique.mockResolvedValue({ aiEngineEnabled: true }); + }); + + it('kör prioriteringskedjan i parseReceipt: user alias -> global alias -> wordmatch -> AI', async () => { + prismaMock.receiptAlias.findMany.mockResolvedValue([ + { + receiptName: 'mixad vara', + product: { + id: 901, + name: 'User Produkt', + canonicalName: 'User Produkt', + categoryRef: { id: 30, name: 'Mejeri, ost & ägg' }, + }, + }, + { + receiptName: 'global choklad', + product: { + id: 900, + name: 'Global Produkt', + canonicalName: 'Global Produkt', + categoryRef: { id: 53, name: 'Choklad' }, + }, + }, + ]); + + prismaMock.product.findMany.mockResolvedValue([ + { + id: 777, + name: 'Specialprodukt', + canonicalName: 'Specialprodukt', + categoryRef: { id: 30, name: 'Mejeri, ost & ägg' }, + }, + ]); + + aiServiceMock.suggestCategory.mockResolvedValue({ + categoryId: 51, + categoryName: 'Godis', + path: 'Glass, godis & snacks > Godis', + confidence: 'low', + }); + + jest + .spyOn(service as any, 'parseReceiptViaImporter') + .mockResolvedValue([ + { rawName: 'MIXAD VARA', quantity: 1, unit: 'st' }, + { rawName: 'GLOBAL CHOKLAD', quantity: 1, unit: 'st' }, + { rawName: 'SPECIALPRODUKT 1st', quantity: 1, unit: 'st' }, + { rawName: 'helt okänd vara', quantity: 1, unit: 'st' }, + ]); + + const file = { + buffer: Buffer.from('dummy'), + mimetype: 'image/jpeg', + originalname: 'receipt.jpg', + } as any; + + const result = await service.parseReceipt(file, false, 10); + + expect(result).toHaveLength(4); + + expect(result[0].matchedVia).toBe('alias'); + expect(result[0].matchedProductId).toBe(901); + + expect(result[1].matchedVia).toBe('alias'); + expect(result[1].matchedProductId).toBe(900); + + expect(result[2].matchedVia).toBe('wordmatch'); + expect(result[2].suggestedProductId).toBe(777); + expect(result[2].categorySuggestion?.categoryId).toBe(30); + + expect(result[3].matchedVia).toBe('none'); + expect(result[3].categorySuggestion?.categoryId).toBe(51); + + expect(aiServiceMock.suggestCategory).toHaveBeenCalledTimes(1); + expect(aiServiceMock.suggestCategory).toHaveBeenCalledWith('helt okänd vara', categories); + }); +}); diff --git a/backend/src/receipt-import/receipt-import.service.spec.ts b/backend/src/receipt-import/receipt-import.service.spec.ts index 3348d1b8..f8a67c65 100644 --- a/backend/src/receipt-import/receipt-import.service.spec.ts +++ b/backend/src/receipt-import/receipt-import.service.spec.ts @@ -287,4 +287,121 @@ describe('ReceiptImportService test matrix', () => { 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); + }); + }); }); \ No newline at end of file