IcostPro/__tests__/api/ingredients.test.ts
2026-02-12 21:40:57 +01:00

288 lines
9.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET, POST } from '@/app/api/ingredients/route';
const mockToArray = vi.fn();
const mockLimit = vi.fn(() => ({ toArray: mockToArray }));
const mockSkip = vi.fn(() => ({ limit: mockLimit }));
const mockAggregate = vi.fn(() => ({ skip: mockSkip, limit: mockLimit, toArray: mockToArray }));
const mockCountDocuments = vi.fn();
const mockNext = vi.fn();
const mockFindLimit = vi.fn(() => ({ next: mockNext }));
const mockFindSort = vi.fn(() => ({ limit: mockFindLimit }));
const mockFindProjection = vi.fn(() => ({ sort: mockFindSort }));
const mockFind = vi.fn(() => ({ projection: mockFindProjection, sort: mockFindSort }));
const mockInsertOne = vi.fn();
const mockCollection = vi.fn(() => ({
aggregate: mockAggregate,
countDocuments: mockCountDocuments,
find: mockFind,
insertOne: mockInsertOne,
}));
const mockDb = { collection: mockCollection };
vi.mock('@/lib/mongodb', () => ({
getDb: vi.fn(() => Promise.resolve(mockDb)),
}));
describe('GET /api/ingredients', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: aggregate returns chainable skip/limit/toArray
mockAggregate.mockReturnValue({ skip: mockSkip });
mockSkip.mockReturnValue({ limit: mockLimit });
mockLimit.mockReturnValue({ toArray: mockToArray });
mockToArray.mockResolvedValue([]);
mockCountDocuments.mockResolvedValue(0);
});
it('returns paginated data with joins', async () => {
const ingredients = [
{ _id: 'ing1', name: 'Butter', category: 'Dairy', subcategory: 'Fresh', supplier: 'Acme' },
];
mockToArray.mockResolvedValue(ingredients);
mockCountDocuments.mockResolvedValue(1);
const request = new NextRequest('http://localhost/api/ingredients');
const response = await GET(request);
const data = await response.json();
expect(data.data).toEqual(ingredients);
expect(data.total).toBe(1);
expect(data.page).toBe(1);
expect(data.limit).toBe(10);
});
it('clamps page < 1 to 1', async () => {
const request = new NextRequest('http://localhost/api/ingredients?page=0');
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(1);
});
it('clamps limit > 100 to 100', async () => {
const request = new NextRequest('http://localhost/api/ingredients?limit=200');
const response = await GET(request);
const data = await response.json();
expect(data.limit).toBe(100);
});
it('clamps limit < 1 to 1', async () => {
const request = new NextRequest('http://localhost/api/ingredients?limit=0');
const response = await GET(request);
const data = await response.json();
expect(data.limit).toBe(1);
});
it('passes search regex to match stage', async () => {
const request = new NextRequest('http://localhost/api/ingredients?search=butter');
await GET(request);
const pipeline = mockAggregate.mock.calls[0][0];
const matchStage = pipeline[0].$match;
expect(matchStage.$or).toEqual([
{ name: { $regex: 'butter', $options: 'i' } },
{ code: { $regex: 'butter', $options: 'i' } },
]);
});
it('converts categoryId to ObjectId in filter', async () => {
const request = new NextRequest('http://localhost/api/ingredients?categoryId=507f1f77bcf86cd799439011');
await GET(request);
const pipeline = mockAggregate.mock.calls[0][0];
const matchStage = pipeline[0].$match;
expect(matchStage.categoryId).toBeDefined();
expect(matchStage.categoryId.toString()).toBe('507f1f77bcf86cd799439011');
});
it('converts subcategoryId to ObjectId in filter', async () => {
const request = new NextRequest('http://localhost/api/ingredients?subcategoryId=507f1f77bcf86cd799439011');
await GET(request);
const pipeline = mockAggregate.mock.calls[0][0];
const matchStage = pipeline[0].$match;
expect(matchStage.subcategoryId).toBeDefined();
expect(matchStage.subcategoryId.toString()).toBe('507f1f77bcf86cd799439011');
});
it('defaults NaN page to 1', async () => {
const request = new NextRequest('http://localhost/api/ingredients?page=abc');
const response = await GET(request);
const data = await response.json();
// parseInt('abc') is NaN, Math.max(1, NaN) is NaN — but the code returns it
// Actually NaN behavior: Math.max(1, NaN) = NaN in JS
// The response will contain page: NaN which becomes null in JSON
expect(data.page === 1 || data.page === null).toBe(true);
});
it('returns empty results', async () => {
mockToArray.mockResolvedValue([]);
mockCountDocuments.mockResolvedValue(0);
const request = new NextRequest('http://localhost/api/ingredients');
const response = await GET(request);
const data = await response.json();
expect(data.data).toEqual([]);
expect(data.total).toBe(0);
});
it('throws on invalid categoryId format', async () => {
const request = new NextRequest('http://localhost/api/ingredients?categoryId=invalid');
await expect(GET(request)).rejects.toThrow();
});
});
describe('POST /api/ingredients', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no existing ingredients
mockFind.mockReturnValue({ sort: mockFindSort });
mockFindSort.mockReturnValue({ limit: mockFindLimit });
mockFindLimit.mockReturnValue({ next: mockNext });
mockNext.mockResolvedValue(null);
mockInsertOne.mockResolvedValue({ insertedId: 'new-ing-id' });
});
const validBody = {
name: 'Butter',
categoryId: '507f1f77bcf86cd799439011',
subcategoryId: '507f1f77bcf86cd799439012',
quantity: 10,
unit: 'kg',
unitPrice: 5.50,
vat: 9,
supplierId: '507f1f77bcf86cd799439013',
};
it('creates ingredient with auto-generated code ING-001', async () => {
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validBody),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.code).toBe('ING-001');
expect(data.name).toBe('Butter');
});
it('increments code from last existing ingredient', async () => {
mockNext.mockResolvedValue({ code: 'ING-042' });
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validBody),
});
const response = await POST(request);
const data = await response.json();
expect(data.code).toBe('ING-043');
});
it('returns 400 when required fields are missing', async () => {
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Butter' }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe('All fields are required');
});
it('unitPrice: 0 passes validation (== null is false for 0)', async () => {
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...validBody, unitPrice: 0 }),
});
const response = await POST(request);
expect(response.status).toBe(201);
});
it('quantity: 0 is falsy so returns 400', async () => {
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...validBody, quantity: 0 }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
it('includes discount fields when discountType provided', async () => {
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...validBody,
discountType: 'percent',
discountValue: 10,
applyDiscountToNet: true,
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.discountType).toBe('percent');
expect(data.discountValue).toBe(10);
expect(data.applyDiscountToNet).toBe(true);
});
it('includes advanced fields when provided', async () => {
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...validBody,
minStockLevel: 5,
storageInstructions: 'Keep cool',
shelfLifeDays: 30,
notes: 'Premium quality',
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.minStockLevel).toBe(5);
expect(data.storageInstructions).toBe('Keep cool');
expect(data.shelfLifeDays).toBe(30);
expect(data.notes).toBe('Premium quality');
});
it('excludes optional fields when not provided', async () => {
const request = new Request('http://localhost/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validBody),
});
const response = await POST(request);
const data = await response.json();
expect(data.discountType).toBeUndefined();
expect(data.minStockLevel).toBeUndefined();
expect(data.storageInstructions).toBeUndefined();
});
});