feat: add tests for QuickImportService and ReceiptImportService parse flow
This commit is contained in:
@@ -44,10 +44,39 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Build NestJS app
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm run build
|
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:
|
backend-full:
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
runs-on: backend-node24
|
runs-on: backend-node24
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -287,4 +287,121 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
expect(result.matchedVia).toBe('none');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user