diff --git a/backend/src/receipt-import/receipt-import.save.spec.ts b/backend/src/receipt-import/receipt-import.save.spec.ts new file mode 100644 index 00000000..80b847d7 --- /dev/null +++ b/backend/src/receipt-import/receipt-import.save.spec.ts @@ -0,0 +1,209 @@ +import { BadRequestException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { ReceiptImportService } from './receipt-import.service'; +import { SaveReceiptDto } from './dto/save-receipt.dto'; + +// ────────────────────────────────────────────────────────────────────────────── +// Hjälpfunktioner +// ────────────────────────────────────────────────────────────────────────────── + +function makeItem(overrides: Partial = {}): SaveReceiptDto['items'][0] { + return { + rawName: 'ARLA MJOLK 1L', + quantity: 2, + unit: 'st', + destination: 'inventory', + productId: 100, + ...overrides, + } as SaveReceiptDto['items'][0]; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Spec +// ────────────────────────────────────────────────────────────────────────────── + +describe('ReceiptImportService.saveReceipt', () => { + let txMock: { + product: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock }; + inventoryItem: { create: jest.Mock; update: jest.Mock }; + pantryItem: { create: jest.Mock }; + receiptAlias: { upsert: jest.Mock }; + unitMapping: { upsert: jest.Mock }; + }; + + let prismaMock: { + pantryItem: { findMany: jest.Mock }; + inventoryItem: { findMany: jest.Mock }; + $transaction: jest.Mock; + }; + + let service: ReceiptImportService; + + beforeEach(() => { + txMock = { + product: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + inventoryItem: { + create: jest.fn().mockResolvedValue({ id: 1 }), + update: jest.fn().mockResolvedValue({ id: 1 }), + }, + pantryItem: { + create: jest.fn().mockResolvedValue({ id: 1 }), + }, + receiptAlias: { + upsert: jest.fn().mockResolvedValue({ id: 1 }), + }, + unitMapping: { + upsert: jest.fn().mockResolvedValue({ id: 1 }), + }, + }; + + prismaMock = { + pantryItem: { findMany: jest.fn().mockResolvedValue([]) }, + inventoryItem: { findMany: jest.fn().mockResolvedValue([]) }, + $transaction: jest.fn().mockImplementation(async (cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + + service = new ReceiptImportService( + prismaMock as any, + {} as any, // aiService – används ej i saveReceipt + {} as any, // categoriesService – används ej i saveReceipt + ); + }); + + // ── 1. Skapar ny inventariepost ───────────────────────────────────────────── + it('skapar ny inventariepost när produkten finns och inte finns i inventariet', async () => { + txMock.product.findUnique.mockResolvedValue({ id: 100, isActive: true }); + + const dto: SaveReceiptDto = { items: [makeItem()] }; + const result = await service.saveReceipt(1, dto); + + expect(txMock.inventoryItem.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ productId: 100, userId: 1 }), + }), + ); + expect(result.created).toBe(1); + expect(result.merged).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + // ── 2. Slår samman mängd när produkten redan finns i inventariet ──────────── + it('slår samman mängd när produkten redan finns i inventariet', async () => { + prismaMock.inventoryItem.findMany.mockResolvedValue([ + { id: 50, productId: 100, quantity: new Prisma.Decimal(3), unit: 'st' }, + ]); + txMock.product.findUnique.mockResolvedValue({ id: 100, isActive: true }); + + const dto: SaveReceiptDto = { items: [makeItem({ quantity: 2 })] }; + const result = await service.saveReceipt(1, dto); + + expect(txMock.inventoryItem.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 50 }, + data: { quantity: { increment: new Prisma.Decimal(2) } }, + }), + ); + expect(result.merged).toBe(1); + expect(result.created).toBe(0); + }); + + // ── 3. Lägger till i skafferi ─────────────────────────────────────────────── + it('lägger till i skafferi när destination är pantry och produkten inte finns där', async () => { + txMock.product.findUnique.mockResolvedValue({ id: 100, isActive: true }); + + const dto: SaveReceiptDto = { items: [makeItem({ destination: 'pantry' })] }; + const result = await service.saveReceipt(1, dto); + + expect(txMock.pantryItem.create).toHaveBeenCalledWith( + expect.objectContaining({ data: { userId: 1, productId: 100 } }), + ); + expect(result.pantryAdded).toBe(1); + expect(result.pantrySkipped).toBe(0); + }); + + // ── 4. Hoppar över skafferidublett ────────────────────────────────────────── + it('hoppar över produkt som redan finns i skafferiet', async () => { + prismaMock.pantryItem.findMany.mockResolvedValue([{ productId: 100 }]); + txMock.product.findUnique.mockResolvedValue({ id: 100, isActive: true }); + + const dto: SaveReceiptDto = { items: [makeItem({ destination: 'pantry' })] }; + const result = await service.saveReceipt(1, dto); + + expect(txMock.pantryItem.create).not.toHaveBeenCalled(); + expect(result.pantrySkipped).toBe(1); + expect(result.pantryAdded).toBe(0); + }); + + // ── 5. Lär in alias ───────────────────────────────────────────────────────── + it('lär in alias när learnAlias är true', async () => { + txMock.product.findUnique.mockResolvedValue({ id: 100, isActive: true }); + + const dto: SaveReceiptDto = { + items: [makeItem({ learnAlias: true, learnAliasGlobally: false })], + }; + const result = await service.saveReceipt(1, dto); + + expect(txMock.receiptAlias.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + receiptName: 'arla mjolk 1l', + productId: 100, + isGlobal: false, + ownerId: 1, + }), + }), + ); + expect(result.aliasesLearned).toBe(1); + }); + + // ── 6. Skapar ny privat produkt via createProductName ─────────────────────── + it('skapar ny produkt när createProductName anges och produkten inte finns', async () => { + txMock.product.findUnique.mockResolvedValue(null); // ingen befintlig + + txMock.product.create.mockResolvedValue({ id: 200 }); + + const dto: SaveReceiptDto = { + items: [ + makeItem({ productId: undefined, createProductName: 'Hemmagjord Senap' }), + ], + }; + const result = await service.saveReceipt(1, dto); + + expect(txMock.product.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: 'Hemmagjord Senap', + isPrivate: true, + ownerId: 1, + }), + }), + ); + expect(result.created).toBe(1); + expect(result.errors).toHaveLength(0); + }); + + // ── 7. Produkt hittades inte – registrerar fel utan att kasta ─────────────── + it('registrerar fel för okänt productId utan att kasta exception', async () => { + txMock.product.findUnique.mockResolvedValue(null); + + const dto: SaveReceiptDto = { items: [makeItem({ productId: 999 })] }; + const result = await service.saveReceipt(1, dto); + + expect(result.errors).toHaveLength(1); + expect(result.errors![0].index).toBe(0); + expect(result.created).toBe(0); + }); + + // ── 8. Kastar BadRequestException om transaktionen misslyckas ─────────────── + it('kastar BadRequestException om $transaction kastar', async () => { + prismaMock.$transaction.mockRejectedValue(new Error('DB krasch')); + + const dto: SaveReceiptDto = { items: [makeItem()] }; + + await expect(service.saveReceipt(1, dto)).rejects.toThrow(BadRequestException); + }); +});