d5f903db98
- 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
211 lines
8.8 KiB
TypeScript
211 lines
8.8 KiB
TypeScript
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);
|
||
});
|
||
});
|