Files
recipe-app/backend/src/receipt-import/receipt-import.save.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

211 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]> = {}): 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<void>) => cb(txMock)),
};
service = new ReceiptImportService(
prismaMock as any,
{} as any, // aiService används ej i saveReceipt
{} as any, // categoriesService används ej i saveReceipt
{ commitReceiptMatches: jest.fn().mockResolvedValue({ boughtCount: 0, ambiguousCount: 0, unmatchedCount: 0 }) } as any,
);
});
// ── 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);
});
});