Tests refactor

This commit is contained in:
Aerilyn Weber 2026-05-19 11:06:03 +09:00
parent 245520fb50
commit 99134d8556
165 changed files with 911 additions and 531 deletions

View file

@ -0,0 +1,487 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BarcodeService } from '../../../src/modules/products/barcode.service.js';
vi.mock('undici', () => ({
request: vi.fn(),
}));
import { request as undiciRequest } from 'undici';
const mockRequest = undiciRequest as ReturnType<typeof vi.fn>;
function makeMockRepo() {
return {
findByBarcode: vi.fn(),
create: vi.fn(),
};
}
describe('BarcodeService', () => {
let service: BarcodeService;
let mockRepo: ReturnType<typeof makeMockRepo>;
beforeEach(() => {
vi.clearAllMocks();
mockRepo = makeMockRepo();
service = new BarcodeService({
productsRepository: mockRepo as unknown as ConstructorParameters<
typeof BarcodeService
>[0]['productsRepository'],
});
});
it('returns cached product from local DB', async () => {
const existing = { _id: 'p1', name: 'Test Product', barcode: '1234567890123' };
mockRepo.findByBarcode.mockResolvedValue(existing);
const result = await service.lookup('hh1', '1234567890123', 'u1');
expect(result.found).toBe(true);
if (result.found) {
expect(result.cached).toBe(true);
expect(result.product).toEqual(existing);
}
expect(mockRequest).not.toHaveBeenCalled();
});
it('calls Open Food Facts when not found locally and caches result', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
const savedProduct = { _id: 'p2', name: 'Nutella', barcode: '3017620422003' };
mockRepo.create.mockResolvedValue(savedProduct);
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Nutella',
brands: 'Ferrero',
categories_tags: ['en:snacks'],
serving_quantity: 15,
nutriments: {
'energy-kcal_serving': 80,
proteins_serving: 0.9,
carbohydrates_serving: 8.5,
fat_serving: 4.7,
fiber_serving: 0.5,
sugars_serving: 8.2,
'saturated-fat_serving': 1.6,
},
},
}),
},
});
const result = await service.lookup('hh1', '3017620422003', 'u1');
expect(result.found).toBe(true);
if (result.found) {
expect(result.cached).toBe(false);
}
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
householdId: 'hh1',
name: 'Nutella',
brand: 'Ferrero',
barcode: '3017620422003',
category: 'snacks',
servingSize: 15,
servingUnit: 'g',
source: 'barcode_lookup',
createdBy: 'u1',
}),
);
});
it('returns found:false when OFF returns 404', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRequest.mockResolvedValue({
statusCode: 404,
body: { json: vi.fn().mockResolvedValue({}) },
});
const result = await service.lookup('hh1', '0000000000000', 'u1');
expect(result.found).toBe(false);
expect(mockRepo.create).not.toHaveBeenCalled();
});
it('returns found:false when OFF returns status 0', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({ status: 0, product: { product_name: 'X' } }),
},
});
const result = await service.lookup('hh1', '0000000000000', 'u1');
expect(result.found).toBe(false);
});
it('returns found:false when OFF product has no product_name', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({ status: 1, product: {} }),
},
});
const result = await service.lookup('hh1', '0000000000000', 'u1');
expect(result.found).toBe(false);
});
it('returns found:false when network request throws', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRequest.mockRejectedValue(new Error('Connection timeout'));
const result = await service.lookup('hh1', '0000000000000', 'u1');
expect(result.found).toBe(false);
});
it('falls back to per-100g nutrition when no serving data', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
const savedProduct = { _id: 'p3', name: 'Plain Rice', barcode: '1111111111111' };
mockRepo.create.mockResolvedValue(savedProduct);
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Plain Rice',
categories_tags: ['en:cereals'],
nutriments: {
'energy-kcal_100g': 130,
proteins_100g: 2.7,
carbohydrates_100g: 28,
fat_100g: 0.3,
},
},
}),
},
});
const result = await service.lookup('hh1', '1111111111111', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
servingSize: 100,
nutrition: expect.objectContaining({
calories: 130,
protein: 2.7,
carbs: 28,
fat: 0.3,
}),
}),
);
});
it('maps category from OFF categories_tags', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p4', name: 'Milk' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Milk',
categories_tags: ['en:dairies'],
nutriments: {
'energy-kcal_100g': 60,
proteins_100g: 3.3,
carbohydrates_100g: 4.7,
fat_100g: 3.2,
},
},
}),
},
});
const result = await service.lookup('hh1', '2222222222222', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ category: 'dairy' }));
});
it('parses serving_size string when serving_quantity is absent', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p5', name: 'Yogurt' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Yogurt',
serving_size: '125 g',
nutriments: {
'energy-kcal_serving': 110,
proteins_serving: 5,
carbohydrates_serving: 15,
fat_serving: 3,
},
},
}),
},
});
const result = await service.lookup('hh1', '3333333333333', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 125 }));
});
it('converts sodium and cholesterol from grams to milligrams', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p6', name: 'Soup' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Soup',
serving_quantity: 250,
nutriments: {
'energy-kcal_serving': 90,
proteins_serving: 4,
carbohydrates_serving: 12,
fat_serving: 2,
sodium_serving: 0.8,
cholesterol_serving: 0.015,
},
},
}),
},
});
const result = await service.lookup('hh1', '4444444444444', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
nutrition: expect.objectContaining({
sodium: 800,
cholesterol: 15,
}),
}),
);
});
it('handles brand with multiple comma-separated values by taking first', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p7', name: 'Multi Brand' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Multi Brand',
brands: 'BrandA, BrandB, BrandC',
nutriments: {
'energy-kcal_100g': 100,
proteins_100g: 5,
carbohydrates_100g: 20,
fat_100g: 2,
},
},
}),
},
});
const result = await service.lookup('hh1', '5555555555555', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ brand: 'BrandA' }));
});
it('returns found:false when OFF product field is missing', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({ status: 1 }),
},
});
const result = await service.lookup('hh1', '6666666666666', 'u1');
expect(result.found).toBe(false);
});
it('defaults category to OTHER when no matching tags', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p8', name: 'Unknown' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Unknown',
categories_tags: ['en:unknown-stuff'],
nutriments: {
'energy-kcal_100g': 50,
proteins_100g: 1,
carbohydrates_100g: 10,
fat_100g: 0.5,
},
},
}),
},
});
const result = await service.lookup('hh1', '7777777777777', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ category: 'other' }));
});
it('handles serving_size with no numeric value', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p9', name: 'Weird Serving' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Weird Serving',
serving_size: 'one portion',
nutriments: {
'energy-kcal_serving': 100,
proteins_serving: 5,
carbohydrates_serving: 10,
fat_serving: 3,
},
},
}),
},
});
const result = await service.lookup('hh1', '8888888888888', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 100 }));
});
it('defaults nutrition to zeros when nutriments is undefined', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p10', name: 'No Nutrition' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'No Nutrition',
},
}),
},
});
const result = await service.lookup('hh1', '9999999999999', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
}),
);
});
it('defaults serving size to 100 when serving_quantity is negative', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p11', name: 'Negative QTY' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Negative QTY',
serving_quantity: -1,
nutriments: {
'energy-kcal_100g': 50,
proteins_100g: 2,
carbohydrates_100g: 8,
fat_100g: 1,
},
},
}),
},
});
const result = await service.lookup('hh1', '1010101010101', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ servingSize: 100 }));
});
it('uses serving fallbacks when _serving nutriments are missing', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.create.mockResolvedValue({ _id: 'p12', name: 'Partial Nutrients' });
mockRequest.mockResolvedValue({
statusCode: 200,
body: {
json: vi.fn().mockResolvedValue({
status: 1,
product: {
product_name: 'Partial Nutrients',
serving_quantity: 50,
nutriments: {
'energy-kcal_100g': 200,
proteins_100g: 10,
carbohydrates_100g: 30,
fat_100g: 5,
fiber_100g: 3,
sugars_100g: 12,
sodium_100g: 0.4,
'saturated-fat_100g': 1.5,
cholesterol_100g: 0.02,
},
},
}),
},
});
const result = await service.lookup('hh1', '1212121212121', 'u1');
expect(result.found).toBe(true);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
servingSize: 50,
nutrition: expect.objectContaining({
calories: 200,
protein: 10,
carbs: 30,
fat: 5,
fiber: 3,
sugar: 12,
sodium: 400,
saturatedFat: 1.5,
cholesterol: 20,
}),
}),
);
});
});

View file

@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest';
import { parseCsv, MAX_FILE_SIZE, MAX_ROWS } from '../../../src/modules/products/csv-parser.js';
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
describe('parseCsv', () => {
it('parses a valid CSV with all columns', () => {
const csv = [
'name,brand,barcode,category,servingSize,servingUnit,densityGPerMl,calories,protein,carbs,fat,fiber,sugar,sodium,saturatedFat,cholesterol,tags',
'Chicken Breast,Tyson,1234567890123,meat,100,g,,165,31,0,3.6,0,0,74,1,85,protein;lean',
].join('\n');
const result = parseCsv(csv);
expect(result.errors).toHaveLength(0);
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
name: 'Chicken Breast',
brand: 'Tyson',
barcode: '1234567890123',
category: ProductCategory.MEAT,
servingSize: 100,
servingUnit: ServingUnit.GRAMS,
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
tags: ['protein', 'lean'],
source: ProductSource.IMPORT,
});
});
it('handles minimal CSV with only name column', () => {
const csv = 'name\nRice\nBeans';
const result = parseCsv(csv);
expect(result.errors).toHaveLength(0);
expect(result.items).toHaveLength(2);
expect(result.items[0]!.name).toBe('Rice');
expect(result.items[0]!.category).toBe(ProductCategory.OTHER);
expect(result.items[0]!.servingUnit).toBe(ServingUnit.GRAMS);
expect(result.items[0]!.servingSize).toBe(100);
});
it('returns error for empty file', () => {
const result = parseCsv('');
expect(result.items).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.message).toBe('Empty file');
});
it('returns error when name column is missing', () => {
const csv = 'brand,category\nNikko,meat';
const result = parseCsv(csv);
expect(result.items).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.message).toContain('Missing required "name" column');
});
it('skips rows with empty name', () => {
const csv = 'name,category\n,meat\nChicken,meat';
const result = parseCsv(csv);
expect(result.items).toHaveLength(1);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.message).toContain('Missing required field: name');
});
it('rejects invalid servingUnit', () => {
const csv = 'name,servingUnit\nFlour,cup';
const result = parseCsv(csv);
expect(result.items).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.message).toContain('Invalid servingUnit');
expect(result.errors[0]!.message).toContain('cup');
});
it('rejects negative servingSize', () => {
const csv = 'name,servingSize\nBad,-10';
const result = parseCsv(csv);
expect(result.items).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.message).toContain('servingSize must be a positive number');
});
it('handles quoted fields with commas', () => {
const csv = 'name,brand\n"Peanut Butter, Crunchy",Jif';
const result = parseCsv(csv);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.name).toBe('Peanut Butter, Crunchy');
expect(result.items[0]!.brand).toBe('Jif');
});
it('handles escaped quotes in CSV', () => {
const csv = 'name,brand\n"8"" Pizza",DiGiorno';
const result = parseCsv(csv);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.name).toBe('8" Pizza');
});
it('uses ml serving unit when specified', () => {
const csv = 'name,servingUnit\nMilk,ml';
const result = parseCsv(csv);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.servingUnit).toBe(ServingUnit.MILLILITERS);
});
it('includes densityGPerMl when provided', () => {
const csv = 'name,densityGPerMl\nOlive Oil,0.92';
const result = parseCsv(csv);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.densityGPerMl).toBe(0.92);
});
it('parses optional nutrition fields', () => {
const csv =
'name,calories,protein,carbs,fat,fiber,sugar,sodium,saturatedFat,cholesterol\nEgg,155,13,1.1,11,0,1.1,124,3.3,373';
const result = parseCsv(csv);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.nutrition).toEqual({
calories: 155,
protein: 13,
carbs: 1.1,
fat: 11,
fiber: 0,
sugar: 1.1,
sodium: 124,
saturatedFat: 3.3,
cholesterol: 373,
});
});
it('handles Windows line endings (CRLF)', () => {
const csv = 'name,category\r\nApple,fruits\r\nBanana,fruits';
const result = parseCsv(csv);
expect(result.items).toHaveLength(2);
});
it('ignores blank lines', () => {
const csv = 'name\n\nApple\n\nBanana\n';
const result = parseCsv(csv);
expect(result.items).toHaveLength(2);
});
it('maps valid category strings', () => {
const csv = 'name,category\nYogurt,dairy\nSalmon,seafood';
const result = parseCsv(csv);
expect(result.items[0]!.category).toBe(ProductCategory.DAIRY);
expect(result.items[1]!.category).toBe(ProductCategory.SEAFOOD);
});
it('defaults invalid category to OTHER', () => {
const csv = 'name,category\nMystery,invalid_cat';
const result = parseCsv(csv);
expect(result.items[0]!.category).toBe(ProductCategory.OTHER);
});
it('exports MAX_FILE_SIZE and MAX_ROWS constants', () => {
expect(MAX_FILE_SIZE).toBe(5 * 1024 * 1024);
expect(MAX_ROWS).toBe(5000);
});
it('handles non-numeric servingSize as error', () => {
const csv = 'name,servingSize\nBad,abc';
const result = parseCsv(csv);
expect(result.items).toHaveLength(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.message).toContain('servingSize must be a positive number');
});
it('handles case-insensitive headers', () => {
const csv = 'Name,Brand,Category,ServingSize,ServingUnit\nTest,Brand1,meat,50,g';
const result = parseCsv(csv);
expect(result.items).toHaveLength(1);
expect(result.items[0]!.name).toBe('Test');
expect(result.items[0]!.brand).toBe('Brand1');
});
});

View file

@ -0,0 +1,298 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProductsRepository } from '../../../src/modules/products/products.repository.js';
const { mockSave, MockProductModel } = vi.hoisted(() => {
const mockSave = vi.fn();
function MockProductModel(this: { save: typeof mockSave }, data: unknown) {
Object.assign(this, data);
this.save = mockSave;
}
Object.assign(MockProductModel, {
findOne: vi.fn(),
find: vi.fn(),
findOneAndUpdate: vi.fn(),
insertMany: vi.fn(),
});
return { mockSave, MockProductModel };
});
vi.mock('../../../src/schemas/product.schema.js', () => ({
ProductModel: MockProductModel,
}));
const { ProductModel } = await import('../../../src/schemas/product.schema.js');
function makeChain(result: unknown = null) {
return {
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
lean: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue(result),
};
}
describe(ProductsRepository.name, () => {
let repo: ProductsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new ProductsRepository();
});
describe('findByHousehold', () => {
it('applies householdId and deletedAt filters', async () => {
const chain = makeChain([]);
vi.mocked(ProductModel.find).mockReturnValue(chain as never);
await repo.findByHousehold('hh1', { limit: 20 });
expect(ProductModel.find).toHaveBeenCalledWith(
expect.objectContaining({ householdId: 'hh1', deletedAt: { $exists: false } }),
);
expect(chain.sort).toHaveBeenCalledWith({ _id: 1 });
expect(chain.limit).toHaveBeenCalledWith(21);
});
it('applies category filter', async () => {
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
await repo.findByHousehold('hh1', { limit: 20, category: 'meat' as never });
expect(ProductModel.find).toHaveBeenCalledWith(expect.objectContaining({ category: 'meat' }));
});
it('applies barcode filter', async () => {
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
await repo.findByHousehold('hh1', { limit: 20, barcode: '1234567890' });
expect(ProductModel.find).toHaveBeenCalledWith(
expect.objectContaining({ barcode: '1234567890' }),
);
});
it('applies text search via q', async () => {
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
await repo.findByHousehold('hh1', { limit: 20, q: 'chicken' });
expect(ProductModel.find).toHaveBeenCalledWith(
expect.objectContaining({ name: { $regex: 'chicken', $options: 'i' } }),
);
});
it('applies tags filter', async () => {
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
await repo.findByHousehold('hh1', { limit: 20, tags: 'organic,fresh' });
expect(ProductModel.find).toHaveBeenCalledWith(
expect.objectContaining({ tags: { $all: ['organic', 'fresh'] } }),
);
});
it('ignores empty tags string', async () => {
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
await repo.findByHousehold('hh1', { limit: 20, tags: '' });
const call = vi.mocked(ProductModel.find).mock.calls[0][0] as Record<string, unknown>;
expect(call).not.toHaveProperty('tags');
});
it('applies cursor filter', async () => {
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
const cursor = Buffer.from('p1').toString('base64');
await repo.findByHousehold('hh1', { limit: 20, cursor });
expect(ProductModel.find).toHaveBeenCalledWith(
expect.objectContaining({ _id: { $gt: 'p1' } }),
);
});
it('returns hasMore=true when extra item exists', async () => {
const items = Array.from({ length: 21 }, (_, i) => ({
_id: { toString: () => `p${i}` },
name: `Item ${i}`,
}));
vi.mocked(ProductModel.find).mockReturnValue(makeChain(items) as never);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.pagination.hasMore).toBe(true);
expect(result.data).toHaveLength(20);
expect(result.pagination.cursor).not.toBeNull();
});
it('returns hasMore=false and null cursor when empty', async () => {
vi.mocked(ProductModel.find).mockReturnValue(makeChain([]) as never);
const result = await repo.findByHousehold('hh1', { limit: 20 });
expect(result.pagination.hasMore).toBe(false);
expect(result.pagination.cursor).toBeNull();
});
});
describe('findById', () => {
it('queries by id and householdId without deletedAt filter', async () => {
const mockProduct = { _id: 'p1', name: 'Apple', householdId: 'hh1' };
const chain = {
lean: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue(mockProduct),
};
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
const result = await repo.findById('p1', 'hh1');
expect(ProductModel.findOne).toHaveBeenCalledWith({ _id: 'p1', householdId: 'hh1' });
expect(result).toEqual(mockProduct);
});
});
describe('findByIds', () => {
it('queries by multiple ids and householdId', async () => {
const products = [
{ _id: 'p1', name: 'Apple' },
{ _id: 'p2', name: 'Banana' },
];
vi.mocked(ProductModel.find).mockReturnValue(makeChain(products) as never);
const result = await repo.findByIds('hh1', ['p1', 'p2']);
expect(ProductModel.find).toHaveBeenCalledWith({
_id: { $in: ['p1', 'p2'] },
householdId: 'hh1',
});
expect(result).toEqual(products);
});
});
describe('findByBarcode', () => {
it('queries by householdId, barcode, and excludes deleted', async () => {
const mockProduct = { _id: 'p1', barcode: '1234567890' };
const chain = {
lean: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue(mockProduct),
};
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
const result = await repo.findByBarcode('hh1', '1234567890');
expect(ProductModel.findOne).toHaveBeenCalledWith({
householdId: 'hh1',
barcode: '1234567890',
deletedAt: { $exists: false },
});
expect(result).toEqual(mockProduct);
});
});
describe('findDuplicate', () => {
it('queries by householdId and name', async () => {
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
await repo.findDuplicate('hh1', 'Apple');
expect(ProductModel.findOne).toHaveBeenCalledWith(
expect.objectContaining({
householdId: 'hh1',
name: 'Apple',
deletedAt: { $exists: false },
}),
);
});
it('includes brand in filter when provided', async () => {
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
await repo.findDuplicate('hh1', 'Apple', 'Dole');
expect(ProductModel.findOne).toHaveBeenCalledWith(expect.objectContaining({ brand: 'Dole' }));
});
it('excludes the given id when excludeId provided', async () => {
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
await repo.findDuplicate('hh1', 'Apple', undefined, 'p1');
expect(ProductModel.findOne).toHaveBeenCalledWith(
expect.objectContaining({ _id: { $ne: 'p1' } }),
);
});
it('does not include _id filter when no excludeId', async () => {
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
await repo.findDuplicate('hh1', 'Apple');
const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
expect(call).not.toHaveProperty('_id');
});
it('does not include brand filter when brand is undefined', async () => {
const chain = { lean: vi.fn().mockReturnThis(), exec: vi.fn().mockResolvedValue(null) };
vi.mocked(ProductModel.findOne).mockReturnValue(chain as never);
await repo.findDuplicate('hh1', 'Apple', undefined);
const call = vi.mocked(ProductModel.findOne).mock.calls[0][0] as Record<string, unknown>;
expect(call).not.toHaveProperty('brand');
});
});
describe('update', () => {
it('calls findOneAndUpdate with correct filter and data', async () => {
vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(makeChain({ _id: 'p1' }) as never);
await repo.update('p1', 'hh1', { name: 'Updated' });
expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
{ $set: { name: 'Updated' } },
{ new: true, lean: true },
);
});
});
describe('softDelete', () => {
it('sets deletedAt on the document', async () => {
vi.mocked(ProductModel.findOneAndUpdate).mockReturnValue(
makeChain({ _id: 'p1', deletedAt: new Date() }) as never,
);
await repo.softDelete('p1', 'hh1');
expect(ProductModel.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'p1', householdId: 'hh1', deletedAt: { $exists: false } },
{ $set: { deletedAt: expect.any(Date) } },
{ new: true, lean: true },
);
});
});
describe('create', () => {
it('saves and returns the new document as plain object', async () => {
const plainDoc = { _id: 'new-id', name: 'Apple' };
mockSave.mockResolvedValue({ toObject: () => plainDoc });
const result = await repo.create({ name: 'Apple' });
expect(mockSave).toHaveBeenCalled();
expect(result).toEqual(plainDoc);
});
});
describe('bulkCreate', () => {
it('calls insertMany with householdId merged into each item', async () => {
vi.mocked(ProductModel.insertMany).mockResolvedValue([] as never);
await repo.bulkCreate('hh1', [{ name: 'Apple' }, { name: 'Banana' }]);
expect(ProductModel.insertMany).toHaveBeenCalledWith(
[
{ name: 'Apple', householdId: 'hh1' },
{ name: 'Banana', householdId: 'hh1' },
],
{ ordered: false },
);
});
});
});

View file

@ -0,0 +1,579 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import { fastifyAwilixPlugin } from '@fastify/awilix';
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => 'mock-jwks'),
jwtVerify: vi.fn().mockResolvedValue({
payload: {
sub: 'kc-1',
email: 'test@example.com',
preferred_username: 'testuser',
realm_access: { roles: ['member'] },
householdIds: ['hh1'],
},
protectedHeader: { alg: 'RS256' },
key: {},
}),
}));
const {
mockFindByHousehold,
mockFindById,
mockFindByBarcode,
mockFindDuplicate,
mockCreate,
mockUpdate,
mockSoftDelete,
mockBulkCreate,
mockBarcodeLookup,
} = vi.hoisted(() => ({
mockFindByHousehold: vi.fn(),
mockFindById: vi.fn(),
mockFindByBarcode: vi.fn(),
mockFindDuplicate: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
mockSoftDelete: vi.fn(),
mockBulkCreate: vi.fn(),
mockBarcodeLookup: vi.fn(),
}));
vi.mock('../../../src/modules/products/products.repository.js', () => ({
ProductsRepository: class {
findByHousehold = mockFindByHousehold;
findById = mockFindById;
findByIds = vi.fn();
findByBarcode = mockFindByBarcode;
findDuplicate = mockFindDuplicate;
create = mockCreate;
update = mockUpdate;
softDelete = mockSoftDelete;
bulkCreate = mockBulkCreate;
},
}));
vi.mock('../../../src/modules/products/barcode.service.js', () => ({
BarcodeService: class {
lookup = mockBarcodeLookup;
},
}));
vi.mock('../../../src/modules/users/users.repository.js', () => ({
UsersRepository: class {
findByKeycloakId = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
upsertFromToken = vi.fn().mockResolvedValue({ householdIds: ['hh1'] });
},
}));
import authPlugin from '../../../src/plugins/auth.plugin.js';
import householdPlugin from '../../../src/plugins/household.plugin.js';
import usersRoutes from '../../../src/modules/users/users.routes.js';
import productsRoutes from '../../../src/modules/products/products.routes.js';
function makeFakeProduct(overrides: Record<string, unknown> = {}) {
return {
_id: 'p1',
householdId: 'hh1',
name: 'Chicken Breast',
category: ProductCategory.MEAT,
servingSize: 100,
servingUnit: ServingUnit.GRAMS,
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
tags: [],
source: ProductSource.MANUAL,
createdBy: 'kc-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe('products.routes', () => {
let app: Awaited<ReturnType<typeof buildTestApp>>;
async function buildTestApp() {
const instance = Fastify({ logger: false });
instance.setValidatorCompiler(validatorCompiler);
instance.setSerializerCompiler(serializerCompiler);
await instance.register(fastifyAwilixPlugin, {
disposeOnClose: true,
disposeOnResponse: true,
strictBooleanEnforced: true,
});
await instance.register(authPlugin);
await instance.register(householdPlugin);
await instance.register(usersRoutes);
await instance.register(productsRoutes);
await instance.ready();
return instance;
}
const authHeaders = { authorization: 'Bearer valid-token' };
beforeEach(async () => {
vi.clearAllMocks();
app = await buildTestApp();
});
afterEach(async () => {
if (app) await app.close();
});
describe('GET /api/v1/households/:householdId/products', () => {
it('returns paginated list', async () => {
const product = makeFakeProduct();
mockFindByHousehold.mockResolvedValue({
data: [product],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].name).toBe('Chicken Breast');
expect(body.pagination.hasMore).toBe(false);
});
it('passes query params to service', async () => {
mockFindByHousehold.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products?q=chicken&category=meat&limit=10',
headers: authHeaders,
});
expect(mockFindByHousehold).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ q: 'chicken', category: ProductCategory.MEAT, limit: 10 }),
);
});
it('handles ObjectId and Date objects in response', async () => {
const product = makeFakeProduct({
_id: { toString: () => 'pid-obj' },
createdAt: { toISOString: () => '2024-01-01T00:00:00.000Z' },
updatedAt: { toISOString: () => '2024-01-02T00:00:00.000Z' },
brand: 'Tyson',
densityGPerMl: 1.05,
});
mockFindByHousehold.mockResolvedValue({
data: [product],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data[0]._id).toBe('pid-obj');
expect(body.data[0].brand).toBe('Tyson');
expect(body.data[0].densityGPerMl).toBe(1.05);
});
});
describe('GET /api/v1/households/:householdId/products/barcode/:code', () => {
it('returns product when found by barcode', async () => {
mockBarcodeLookup.mockResolvedValue({
found: true,
product: makeFakeProduct({ barcode: '1234567890' }),
cached: true,
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products/barcode/1234567890',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Chicken Breast');
});
it('returns 404 when barcode not found', async () => {
mockBarcodeLookup.mockResolvedValue({ found: false });
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products/barcode/9999999999',
headers: authHeaders,
});
expect(res.statusCode).toBe(404);
expect(res.json().found).toBe(false);
});
});
describe('GET /api/v1/households/:householdId/products/:id', () => {
it('returns a product', async () => {
mockFindById.mockResolvedValue(makeFakeProduct());
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products/p1',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Chicken Breast');
});
it('returns 404 when product not found', async () => {
mockFindById.mockResolvedValue(null);
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products/missing',
headers: authHeaders,
});
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/v1/households/:householdId/products', () => {
it('creates a product and returns 201', async () => {
mockFindByBarcode.mockResolvedValue(null);
mockFindDuplicate.mockResolvedValue(null);
mockCreate.mockResolvedValue(makeFakeProduct());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: {
name: 'Chicken Breast',
category: ProductCategory.MEAT,
servingSize: 100,
servingUnit: ServingUnit.GRAMS,
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
},
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Chicken Breast');
});
it('returns 409 on barcode conflict', async () => {
mockFindByBarcode.mockResolvedValue(makeFakeProduct());
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: {
name: 'Chicken Breast',
category: ProductCategory.MEAT,
servingSize: 100,
servingUnit: ServingUnit.GRAMS,
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
barcode: '1234567890',
},
});
expect(res.statusCode).toBe(409);
});
it('returns 400 on validation failure', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { name: '' }, // missing required fields
});
expect(res.statusCode).toBe(400);
});
});
describe('PATCH /api/v1/households/:householdId/products/:id', () => {
it('updates product', async () => {
const product = makeFakeProduct();
mockFindById.mockResolvedValue(product);
mockFindByBarcode.mockResolvedValue(null);
mockFindDuplicate.mockResolvedValue(null);
mockUpdate.mockResolvedValue({ ...product, name: 'Updated' });
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/products/p1',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { name: 'Updated' },
});
expect(res.statusCode).toBe(200);
expect(res.json().name).toBe('Updated');
});
it('returns 404 for unknown product', async () => {
mockFindById.mockResolvedValue(null);
const res = await app.inject({
method: 'PATCH',
url: '/api/v1/households/hh1/products/missing',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { name: 'X' },
});
expect(res.statusCode).toBe(404);
});
});
describe('DELETE /api/v1/households/:householdId/products/:id', () => {
it('returns 204 on successful delete', async () => {
const product = makeFakeProduct();
mockFindById.mockResolvedValue(product);
mockSoftDelete.mockResolvedValue({ ...product, deletedAt: new Date() });
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/products/p1',
headers: authHeaders,
});
expect(res.statusCode).toBe(204);
});
it('returns 404 for unknown product', async () => {
mockFindById.mockResolvedValue(null);
const res = await app.inject({
method: 'DELETE',
url: '/api/v1/households/hh1/products/missing',
headers: authHeaders,
});
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/v1/households/:householdId/products/smart-add', () => {
it('returns available:false with NoOp provider', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products/smart-add',
headers: { ...authHeaders, 'content-type': 'application/json' },
payload: { text: 'chicken breast 100g' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ available: false, message: 'LLM not configured' });
});
});
describe('POST /api/v1/households/:householdId/products/import', () => {
it('imports products from CSV file', async () => {
mockFindByBarcode.mockResolvedValue(null);
mockFindDuplicate.mockResolvedValue(null);
mockBulkCreate.mockResolvedValue([]);
const csv =
'name,category,servingSize,servingUnit,calories,protein,carbs,fat\nRice,grains,100,g,130,2.7,28,0.3';
const boundary = '----FormBoundary';
const body = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="products.csv"',
'Content-Type: text/csv',
'',
csv,
`--${boundary}--`,
].join('\r\n');
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products/import',
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: body,
});
expect(res.statusCode).toBe(200);
const json = res.json();
expect(json.imported).toBe(1);
expect(json.skipped).toBe(0);
expect(json.errors).toHaveLength(0);
});
it('imports products from JSON file', async () => {
mockFindByBarcode.mockResolvedValue(null);
mockFindDuplicate.mockResolvedValue(null);
mockBulkCreate.mockResolvedValue([]);
const jsonData = JSON.stringify([
{
name: 'Beans',
category: 'legumes',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 120, protein: 8, carbs: 20, fat: 0.5 },
tags: [],
},
]);
const boundary = '----FormBoundary';
const body = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="products.json"',
'Content-Type: application/json',
'',
jsonData,
`--${boundary}--`,
].join('\r\n');
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products/import',
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: body,
});
expect(res.statusCode).toBe(200);
expect(res.json().imported).toBe(1);
});
it('returns 400 when no file uploaded', async () => {
const boundary = '----FormBoundary';
const body = `--${boundary}--`;
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products/import',
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: body,
});
expect(res.statusCode).toBe(400);
});
it('returns 400 for invalid JSON', async () => {
const boundary = '----FormBoundary';
const body = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="bad.json"',
'Content-Type: application/json',
'',
'{not valid json',
`--${boundary}--`,
].join('\r\n');
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products/import',
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: body,
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('Invalid JSON');
});
it('returns 400 when JSON is not an array', async () => {
const boundary = '----FormBoundary';
const body = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="obj.json"',
'Content-Type: application/json',
'',
'{"name": "not an array"}',
`--${boundary}--`,
].join('\r\n');
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products/import',
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: body,
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('JSON must be an array');
});
it('returns 400 when JSON exceeds max rows', async () => {
const items = Array.from({ length: 5001 }, (_, i) => ({
name: `Item ${i}`,
category: 'other',
servingSize: 100,
servingUnit: 'g',
nutrition: { calories: 0, protein: 0, carbs: 0, fat: 0 },
tags: [],
}));
const boundary = '----FormBoundary';
const body = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="big.json"',
'Content-Type: application/json',
'',
JSON.stringify(items),
`--${boundary}--`,
].join('\r\n');
const res = await app.inject({
method: 'POST',
url: '/api/v1/households/hh1/products/import',
headers: { ...authHeaders, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: body,
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('5000');
});
});
describe('toProductResponse optional fields', () => {
it('includes optional nutrition fields and imageUrl when present', async () => {
mockFindByHousehold.mockResolvedValue({
data: [
makeFakeProduct({
densityGPerMl: 1.1,
imageUrl: 'https://example.com/img.jpg',
deletedAt: new Date().toISOString(),
nutrition: {
calories: 100,
protein: 5,
carbs: 10,
fat: 2,
fiber: 3,
sugar: 1,
sodium: 50,
saturatedFat: 0.5,
cholesterol: 10,
},
}),
],
pagination: { cursor: null, hasMore: false },
});
const res = await app.inject({
method: 'GET',
url: '/api/v1/households/hh1/products',
headers: authHeaders,
});
expect(res.statusCode).toBe(200);
const product = res.json().data[0];
expect(product.densityGPerMl).toBe(1.1);
expect(product.imageUrl).toBe('https://example.com/img.jpg');
expect(product.deletedAt).toBeDefined();
expect(product.nutrition.fiber).toBe(3);
expect(product.nutrition.sugar).toBe(1);
expect(product.nutrition.sodium).toBe(50);
expect(product.nutrition.saturatedFat).toBe(0.5);
expect(product.nutrition.cholesterol).toBe(10);
});
});
});

View file

@ -0,0 +1,297 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProductsService } from '../../../src/modules/products/products.service.js';
import { NotFoundError, ConflictError } from '../../../src/common/errors.js';
import { ProductCategory, ServingUnit, ProductSource } from '@meshitrack/shared';
const mockRepo = {
findByHousehold: vi.fn(),
findById: vi.fn(),
findByIds: vi.fn(),
findByBarcode: vi.fn(),
findDuplicate: vi.fn(),
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
bulkCreate: vi.fn(),
};
function makeProduct(overrides: Record<string, unknown> = {}) {
return {
_id: 'p1',
householdId: 'hh1',
name: 'Chicken Breast',
category: ProductCategory.MEAT,
servingSize: 100,
servingUnit: ServingUnit.GRAMS,
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
tags: [],
source: ProductSource.MANUAL,
createdBy: 'u1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
const createData = {
name: 'Chicken Breast',
category: ProductCategory.MEAT,
servingSize: 100,
servingUnit: ServingUnit.GRAMS,
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
tags: [],
source: ProductSource.MANUAL,
};
describe(ProductsService.name, () => {
let service: ProductsService;
beforeEach(() => {
vi.clearAllMocks();
service = new ProductsService({ productsRepository: mockRepo as never });
});
describe('list', () => {
it('delegates to repository', async () => {
const expected = { data: [], pagination: { cursor: null, hasMore: false } };
mockRepo.findByHousehold.mockResolvedValue(expected);
const result = await service.list('hh1', { limit: 20 });
expect(mockRepo.findByHousehold).toHaveBeenCalledWith('hh1', { limit: 20 });
expect(result).toEqual(expected);
});
});
describe('getById', () => {
it('returns product when found', async () => {
const product = makeProduct();
mockRepo.findById.mockResolvedValue(product);
const result = await service.getById('p1', 'hh1');
expect(result).toEqual(product);
});
it('throws NotFoundError when product not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getById('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates product when no barcode conflict and no duplicate', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.create.mockResolvedValue(makeProduct());
const result = await service.create(createData, 'hh1', 'u1');
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Chicken Breast', householdId: 'hh1', createdBy: 'u1' }),
);
expect(result._id).toBe('p1');
});
it('does not check barcode when none provided', async () => {
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.create.mockResolvedValue(makeProduct());
await service.create(createData, 'hh1', 'u1');
expect(mockRepo.findByBarcode).not.toHaveBeenCalled();
});
it('throws ConflictError when barcode already exists', async () => {
mockRepo.findByBarcode.mockResolvedValue(makeProduct({ barcode: '1234567890' }));
await expect(
service.create({ ...createData, barcode: '1234567890' }, 'hh1', 'u1'),
).rejects.toThrow(ConflictError);
});
it('throws ConflictError when duplicate name+brand exists', async () => {
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.findDuplicate.mockResolvedValue(makeProduct());
await expect(service.create(createData, 'hh1', 'u1')).rejects.toThrow(ConflictError);
});
it('uses ProductSource.MANUAL as default source', async () => {
const dataWithoutSource = { ...createData };
delete (dataWithoutSource as Partial<typeof createData>).source;
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.create.mockResolvedValue(makeProduct());
await service.create(dataWithoutSource, 'hh1', 'u1');
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ source: ProductSource.MANUAL }),
);
});
});
describe('update', () => {
it('updates product successfully', async () => {
const product = makeProduct();
mockRepo.findById.mockResolvedValue(product);
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.update.mockResolvedValue({ ...product, name: 'Updated' });
const result = await service.update('p1', 'hh1', { name: 'Updated' });
expect(result.name).toBe('Updated');
});
it('throws NotFoundError when product does not exist', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.update('missing', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when barcode belongs to another product', async () => {
mockRepo.findById.mockResolvedValue(makeProduct());
mockRepo.findByBarcode.mockResolvedValue(makeProduct({ _id: 'p2' }));
await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).rejects.toThrow(
ConflictError,
);
});
it('does not throw barcode conflict when barcode belongs to same product', async () => {
const product = makeProduct({ barcode: '1234567890' });
mockRepo.findById.mockResolvedValue(product);
mockRepo.findByBarcode.mockResolvedValue(product);
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.update.mockResolvedValue(product);
await expect(service.update('p1', 'hh1', { barcode: '1234567890' })).resolves.toBeDefined();
});
it('throws ConflictError when name+brand already taken by another', async () => {
const product = makeProduct();
mockRepo.findById.mockResolvedValue(product);
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.findDuplicate.mockResolvedValue(makeProduct({ _id: 'p2' }));
await expect(service.update('p1', 'hh1', { name: 'Chicken Breast' })).rejects.toThrow(
ConflictError,
);
});
it('throws NotFoundError when update returns null', async () => {
const product = makeProduct();
mockRepo.findById.mockResolvedValue(product);
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.update.mockResolvedValue(null);
await expect(service.update('p1', 'hh1', { name: 'X' })).rejects.toThrow(NotFoundError);
});
it('uses current brand when data.brand is not provided in dedup check', async () => {
const product = makeProduct({ brand: 'BrandA' });
mockRepo.findById
.mockResolvedValueOnce(product) // getById call
.mockResolvedValueOnce(product); // second findById call in update
mockRepo.findByBarcode.mockResolvedValue(null);
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.update.mockResolvedValue(product);
await service.update('p1', 'hh1', { name: 'New Name' });
expect(mockRepo.findDuplicate).toHaveBeenCalledWith('hh1', 'New Name', 'BrandA', 'p1');
});
});
describe('delete', () => {
it('soft-deletes the product', async () => {
const product = makeProduct();
mockRepo.findById.mockResolvedValue(product);
mockRepo.softDelete.mockResolvedValue({ ...product, deletedAt: new Date() });
await service.delete('p1', 'hh1');
expect(mockRepo.softDelete).toHaveBeenCalledWith('p1', 'hh1');
});
it('throws NotFoundError when product does not exist', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.delete('missing', 'hh1')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when softDelete returns null', async () => {
mockRepo.findById.mockResolvedValue(makeProduct());
mockRepo.softDelete.mockResolvedValue(null);
await expect(service.delete('p1', 'hh1')).rejects.toThrow(NotFoundError);
});
});
describe('importProducts', () => {
it('imports products skipping duplicates', async () => {
const { source: _s, ...importData } = createData;
const items = [
{ ...importData, name: 'Item A' },
{ ...importData, name: 'Item B', barcode: '111' },
{ ...importData, name: 'Item C' },
];
// Item B has a barcode collision
mockRepo.findByBarcode.mockResolvedValueOnce(makeProduct()); // Item B barcode exists
mockRepo.findDuplicate
.mockResolvedValueOnce(null) // Item A ok
.mockResolvedValueOnce(makeProduct()); // Item C duplicate
mockRepo.bulkCreate.mockResolvedValue([]);
const result = await service.importProducts('hh1', 'u1', items as never);
expect(result.imported).toBe(1); // Item A
expect(result.skipped).toBe(2); // Item B (barcode), Item C (duplicate)
expect(result.errors).toHaveLength(0);
expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
'hh1',
expect.arrayContaining([
expect.objectContaining({ name: 'Item A', source: ProductSource.IMPORT }),
]),
);
});
it('does not call bulkCreate when all items are skipped', async () => {
mockRepo.findDuplicate.mockResolvedValue(makeProduct());
const result = await service.importProducts('hh1', 'u1', [createData]);
expect(result.imported).toBe(0);
expect(result.skipped).toBe(1);
expect(mockRepo.bulkCreate).not.toHaveBeenCalled();
});
it('respects provided source over IMPORT default', async () => {
mockRepo.findDuplicate.mockResolvedValue(null);
mockRepo.bulkCreate.mockResolvedValue([]);
await service.importProducts('hh1', 'u1', [
{ ...createData, source: ProductSource.BARCODE_LOOKUP },
]);
expect(mockRepo.bulkCreate).toHaveBeenCalledWith(
'hh1',
expect.arrayContaining([expect.objectContaining({ source: ProductSource.BARCODE_LOOKUP })]),
);
});
it('records error when repo throws during item processing', async () => {
mockRepo.findByBarcode.mockRejectedValue(new Error('DB error'));
mockRepo.bulkCreate.mockResolvedValue([]);
const result = await service.importProducts('hh1', 'u1', [{ ...createData, barcode: '111' }]);
expect(result.imported).toBe(0);
expect(result.skipped).toBe(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatchObject({ row: 1, message: 'Validation error' });
});
});
});