Add tests
This commit is contained in:
parent
23ac861032
commit
6a71cc2d54
117
README.md
117
README.md
@ -7,10 +7,11 @@
|
||||
## Table of Contents
|
||||
1. [Project Overview](#project-overview)
|
||||
2. [Folder Structure](#folder-structure)
|
||||
3. [Coding Patterns & Conventions](#coding-patterns--conventions)
|
||||
4. [Component Architecture](#component-architecture)
|
||||
5. [API Design & Data Flow](#api-design--data-flow)
|
||||
6. [Adding New Features](#adding-new-features)
|
||||
3. [Testing](#testing)
|
||||
4. [Coding Patterns & Conventions](#coding-patterns--conventions)
|
||||
5. [Component Architecture](#component-architecture)
|
||||
6. [API Design & Data Flow](#api-design--data-flow)
|
||||
7. [Adding New Features](#adding-new-features)
|
||||
|
||||
---
|
||||
|
||||
@ -104,6 +105,107 @@ lib/
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
**Stack**: Vitest + React Testing Library + jsdom
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
npm test # Run in watch mode
|
||||
npx vitest run # Run all tests once
|
||||
npx vitest run --reporter=verbose # Detailed output
|
||||
npx vitest run __tests__/api # Run only API tests
|
||||
npx vitest run __tests__/components # Run only component tests
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
__tests__/
|
||||
├── api/ # API route tests (mock MongoDB)
|
||||
│ ├── categories.test.ts
|
||||
│ ├── subcategories.test.ts
|
||||
│ ├── suppliers.test.ts
|
||||
│ ├── ingredients.test.ts
|
||||
│ └── ingredients-id.test.ts
|
||||
└── components/ # Component tests (mock fetch)
|
||||
├── DeleteConfirmModal.test.tsx
|
||||
├── IngredientTable.test.tsx
|
||||
├── ViewIngredientModal.test.tsx
|
||||
└── AddIngredientModal.test.tsx
|
||||
```
|
||||
|
||||
### Writing API Route Tests
|
||||
|
||||
API tests mock `getDb()` from `@/lib/mongodb` to return a fake db object with chainable collection methods. No real database connection is needed.
|
||||
|
||||
```typescript
|
||||
const mockToArray = vi.fn();
|
||||
const mockSort = vi.fn(() => ({ toArray: mockToArray }));
|
||||
const mockFind = vi.fn(() => ({ sort: mockSort }));
|
||||
const mockInsertOne = vi.fn();
|
||||
const mockCollection = vi.fn(() => ({
|
||||
find: mockFind,
|
||||
insertOne: mockInsertOne,
|
||||
}));
|
||||
const mockDb = { collection: mockCollection };
|
||||
|
||||
vi.mock('@/lib/mongodb', () => ({
|
||||
getDb: vi.fn(() => Promise.resolve(mockDb)),
|
||||
}));
|
||||
```
|
||||
|
||||
Then import and call the route handler directly:
|
||||
```typescript
|
||||
import { GET, POST } from '@/app/api/categories/route';
|
||||
|
||||
it('returns sorted categories', async () => {
|
||||
mockToArray.mockResolvedValue([{ _id: 'id1', name: 'Dairy' }]);
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
expect(data).toEqual([{ _id: 'id1', name: 'Dairy' }]);
|
||||
});
|
||||
```
|
||||
|
||||
### Writing Component Tests
|
||||
|
||||
Component tests mock `fetch` globally and use `within(container)` for scoped queries (required for React 19 compatibility). Always call `cleanup()` in `afterEach`.
|
||||
|
||||
```typescript
|
||||
import { render, fireEvent, waitFor, cleanup, within } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('loads data on open', async () => {
|
||||
vi.spyOn(global, 'fetch').mockResolvedValue({
|
||||
json: () => Promise.resolve(mockData),
|
||||
} as Response);
|
||||
|
||||
const { container } = render(<MyModal open={true} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Expected Text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Config Files
|
||||
|
||||
- **`vitest.config.ts`** — Vitest config with React plugin, jsdom environment, `@/` path alias
|
||||
- **`vitest.setup.ts`** — Imports `@testing-library/jest-dom/vitest` for DOM matchers (`.toBeInTheDocument()`, `.toBeDisabled()`, etc.)
|
||||
|
||||
### Adding Tests for New Features
|
||||
|
||||
When adding a new entity or component, create a corresponding test file:
|
||||
|
||||
1. **New API route** → `__tests__/api/[resource].test.ts` — Mock `getDb()`, test each HTTP method, cover validation and edge cases
|
||||
2. **New component** → `__tests__/components/[Component].test.tsx` — Mock `fetch`, test render/hide, user interactions, loading states, and error handling
|
||||
|
||||
---
|
||||
|
||||
## Coding Patterns & Conventions
|
||||
|
||||
### TypeScript Interfaces
|
||||
@ -951,6 +1053,13 @@ When adding new code, ensure you follow these practices:
|
||||
- [ ] Page-specific components stay in the page's folder, NOT in global `/components/`
|
||||
- [ ] Utilities/types in `/lib/`
|
||||
|
||||
**Testing**:
|
||||
- [ ] API route tests mock `getDb()` and test each HTTP method
|
||||
- [ ] Component tests mock `fetch` and use `within(container)` queries
|
||||
- [ ] Cover validation errors, edge cases, and error paths
|
||||
- [ ] Call `cleanup()` in `afterEach` for component tests
|
||||
- [ ] Run `npx vitest run` before committing to verify all tests pass
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns Reference
|
||||
|
||||
89
__tests__/api/categories.test.ts
Normal file
89
__tests__/api/categories.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GET, POST } from '@/app/api/categories/route';
|
||||
|
||||
const mockToArray = vi.fn();
|
||||
const mockSort = vi.fn(() => ({ toArray: mockToArray }));
|
||||
const mockFind = vi.fn(() => ({ sort: mockSort }));
|
||||
const mockInsertOne = vi.fn();
|
||||
const mockCollection = vi.fn(() => ({
|
||||
find: mockFind,
|
||||
insertOne: mockInsertOne,
|
||||
}));
|
||||
const mockDb = { collection: mockCollection };
|
||||
|
||||
vi.mock('@/lib/mongodb', () => ({
|
||||
getDb: vi.fn(() => Promise.resolve(mockDb)),
|
||||
}));
|
||||
|
||||
describe('GET /api/categories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns sorted categories', async () => {
|
||||
const categories = [
|
||||
{ _id: 'id1', name: 'Dairy' },
|
||||
{ _id: 'id2', name: 'Vegetables' },
|
||||
];
|
||||
mockToArray.mockResolvedValue(categories);
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockCollection).toHaveBeenCalledWith('categories');
|
||||
expect(mockSort).toHaveBeenCalledWith({ name: 1 });
|
||||
expect(data).toEqual(categories);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/categories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates category with name and returns 201', async () => {
|
||||
mockInsertOne.mockResolvedValue({ insertedId: 'new-id' });
|
||||
|
||||
const request = new Request('http://localhost/api/categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Spices' }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.name).toBe('Spices');
|
||||
expect(data._id).toBe('new-id');
|
||||
expect(data.createdAt).toBeDefined();
|
||||
expect(data.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const request = new Request('http://localhost/api/categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('Name is required');
|
||||
});
|
||||
|
||||
it('passes when name is empty string (documents current behavior)', async () => {
|
||||
const request = new Request('http://localhost/api/categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: '' }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
// Empty string is falsy, so !name is true → 400
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
193
__tests__/api/ingredients-id.test.ts
Normal file
193
__tests__/api/ingredients-id.test.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GET, PUT, DELETE } from '@/app/api/ingredients/[id]/route';
|
||||
|
||||
const mockNext = vi.fn();
|
||||
const mockAggregate = vi.fn(() => ({ next: mockNext }));
|
||||
const mockFindOneAndUpdate = vi.fn();
|
||||
const mockDeleteOne = vi.fn();
|
||||
const mockCollection = vi.fn(() => ({
|
||||
aggregate: mockAggregate,
|
||||
findOneAndUpdate: mockFindOneAndUpdate,
|
||||
deleteOne: mockDeleteOne,
|
||||
}));
|
||||
const mockDb = { collection: mockCollection };
|
||||
|
||||
vi.mock('@/lib/mongodb', () => ({
|
||||
getDb: vi.fn(() => Promise.resolve(mockDb)),
|
||||
}));
|
||||
|
||||
const validId = '507f1f77bcf86cd799439011';
|
||||
|
||||
function makeParams(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
describe('GET /api/ingredients/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns single ingredient with lookups', async () => {
|
||||
const ingredient = {
|
||||
_id: validId,
|
||||
code: 'ING-001',
|
||||
name: 'Butter',
|
||||
category: 'Dairy',
|
||||
subcategory: 'Fresh',
|
||||
supplier: 'Acme',
|
||||
};
|
||||
mockNext.mockResolvedValue(ingredient);
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`);
|
||||
const response = await GET(request, makeParams(validId));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.name).toBe('Butter');
|
||||
expect(data.category).toBe('Dairy');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid ID format', async () => {
|
||||
const request = new Request('http://localhost/api/ingredients/invalid-id');
|
||||
const response = await GET(request, makeParams('invalid-id'));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('Invalid ID');
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent ID', async () => {
|
||||
mockNext.mockResolvedValue(null);
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`);
|
||||
const response = await GET(request, makeParams(validId));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.error).toBe('Ingredient not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/ingredients/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('updates specific fields and sets updatedAt', async () => {
|
||||
const updated = { _id: validId, name: 'Updated Butter', updatedAt: new Date() };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Updated Butter' }),
|
||||
});
|
||||
|
||||
const response = await PUT(request, makeParams(validId));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.name).toBe('Updated Butter');
|
||||
|
||||
const updateArg = mockFindOneAndUpdate.mock.calls[0][1].$set;
|
||||
expect(updateArg.name).toBe('Updated Butter');
|
||||
expect(updateArg.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 for invalid ID', async () => {
|
||||
const request = new Request('http://localhost/api/ingredients/bad', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'x' }),
|
||||
});
|
||||
|
||||
const response = await PUT(request, makeParams('bad'));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent ID', async () => {
|
||||
mockFindOneAndUpdate.mockResolvedValue(null);
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'x' }),
|
||||
});
|
||||
|
||||
const response = await PUT(request, makeParams(validId));
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('empty body only sets updatedAt', async () => {
|
||||
const updated = { _id: validId, updatedAt: new Date() };
|
||||
mockFindOneAndUpdate.mockResolvedValue(updated);
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const response = await PUT(request, makeParams(validId));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const updateArg = mockFindOneAndUpdate.mock.calls[0][1].$set;
|
||||
expect(Object.keys(updateArg)).toEqual(['updatedAt']);
|
||||
});
|
||||
|
||||
it('converts ObjectId fields (categoryId, subcategoryId, supplierId)', async () => {
|
||||
const catId = '507f1f77bcf86cd799439012';
|
||||
const subId = '507f1f77bcf86cd799439013';
|
||||
const supId = '507f1f77bcf86cd799439014';
|
||||
mockFindOneAndUpdate.mockResolvedValue({ _id: validId });
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ categoryId: catId, subcategoryId: subId, supplierId: supId }),
|
||||
});
|
||||
|
||||
const response = await PUT(request, makeParams(validId));
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const updateArg = mockFindOneAndUpdate.mock.calls[0][1].$set;
|
||||
expect(updateArg.categoryId.toString()).toBe(catId);
|
||||
expect(updateArg.subcategoryId.toString()).toBe(subId);
|
||||
expect(updateArg.supplierId.toString()).toBe(supId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/ingredients/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('removes ingredient and returns success', async () => {
|
||||
mockDeleteOne.mockResolvedValue({ deletedCount: 1 });
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`, { method: 'DELETE' });
|
||||
const response = await DELETE(request, makeParams(validId));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 for invalid ID', async () => {
|
||||
const request = new Request('http://localhost/api/ingredients/bad', { method: 'DELETE' });
|
||||
const response = await DELETE(request, makeParams('bad'));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent ID', async () => {
|
||||
mockDeleteOne.mockResolvedValue({ deletedCount: 0 });
|
||||
|
||||
const request = new Request(`http://localhost/api/ingredients/${validId}`, { method: 'DELETE' });
|
||||
const response = await DELETE(request, makeParams(validId));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.error).toBe('Ingredient not found');
|
||||
});
|
||||
});
|
||||
287
__tests__/api/ingredients.test.ts
Normal file
287
__tests__/api/ingredients.test.ts
Normal file
@ -0,0 +1,287 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
115
__tests__/api/subcategories.test.ts
Normal file
115
__tests__/api/subcategories.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET, POST } from '@/app/api/subcategories/route';
|
||||
|
||||
const mockToArray = vi.fn();
|
||||
const mockSort = vi.fn(() => ({ toArray: mockToArray }));
|
||||
const mockFind = vi.fn(() => ({ sort: mockSort }));
|
||||
const mockInsertOne = vi.fn();
|
||||
const mockCollection = vi.fn(() => ({
|
||||
find: mockFind,
|
||||
insertOne: mockInsertOne,
|
||||
}));
|
||||
const mockDb = { collection: mockCollection };
|
||||
|
||||
vi.mock('@/lib/mongodb', () => ({
|
||||
getDb: vi.fn(() => Promise.resolve(mockDb)),
|
||||
}));
|
||||
|
||||
describe('GET /api/subcategories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns all subcategories when no filter', async () => {
|
||||
const subcategories = [
|
||||
{ _id: 'sub1', name: 'Fresh', categoryId: 'cat1' },
|
||||
{ _id: 'sub2', name: 'Frozen', categoryId: 'cat1' },
|
||||
];
|
||||
mockToArray.mockResolvedValue(subcategories);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/subcategories');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockFind).toHaveBeenCalledWith({});
|
||||
expect(data).toEqual(subcategories);
|
||||
});
|
||||
|
||||
it('filters by categoryId when provided', async () => {
|
||||
mockToArray.mockResolvedValue([]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/subcategories?categoryId=507f1f77bcf86cd799439011');
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockFind).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ categoryId: expect.any(Object) })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid categoryId format', async () => {
|
||||
const request = new NextRequest('http://localhost/api/subcategories?categoryId=invalid');
|
||||
|
||||
await expect(GET(request)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/subcategories', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates subcategory with name and categoryId, returns 201', async () => {
|
||||
mockInsertOne.mockResolvedValue({ insertedId: 'new-sub-id' });
|
||||
|
||||
const request = new Request('http://localhost/api/subcategories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Fresh Herbs', categoryId: '507f1f77bcf86cd799439011' }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.name).toBe('Fresh Herbs');
|
||||
expect(data._id).toBe('new-sub-id');
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const request = new Request('http://localhost/api/subcategories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ categoryId: '507f1f77bcf86cd799439011' }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when categoryId is missing', async () => {
|
||||
const request = new Request('http://localhost/api/subcategories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Fresh' }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when both name and categoryId are missing', async () => {
|
||||
const request = new Request('http://localhost/api/subcategories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('Name and categoryId are required');
|
||||
});
|
||||
});
|
||||
86
__tests__/api/suppliers.test.ts
Normal file
86
__tests__/api/suppliers.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GET, POST } from '@/app/api/suppliers/route';
|
||||
|
||||
const mockToArray = vi.fn();
|
||||
const mockSort = vi.fn(() => ({ toArray: mockToArray }));
|
||||
const mockFind = vi.fn(() => ({ sort: mockSort }));
|
||||
const mockInsertOne = vi.fn();
|
||||
const mockCollection = vi.fn(() => ({
|
||||
find: mockFind,
|
||||
insertOne: mockInsertOne,
|
||||
}));
|
||||
const mockDb = { collection: mockCollection };
|
||||
|
||||
vi.mock('@/lib/mongodb', () => ({
|
||||
getDb: vi.fn(() => Promise.resolve(mockDb)),
|
||||
}));
|
||||
|
||||
describe('GET /api/suppliers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns sorted suppliers', async () => {
|
||||
const suppliers = [
|
||||
{ _id: 'sup1', name: 'Acme Foods' },
|
||||
{ _id: 'sup2', name: 'Best Produce' },
|
||||
];
|
||||
mockToArray.mockResolvedValue(suppliers);
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockCollection).toHaveBeenCalledWith('suppliers');
|
||||
expect(mockSort).toHaveBeenCalledWith({ name: 1 });
|
||||
expect(data).toEqual(suppliers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/suppliers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates supplier and returns 201', async () => {
|
||||
mockInsertOne.mockResolvedValue({ insertedId: 'new-sup' });
|
||||
|
||||
const request = new Request('http://localhost/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Fresh Farms' }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.name).toBe('Fresh Farms');
|
||||
expect(data._id).toBe('new-sup');
|
||||
expect(data.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 400 when name is missing', async () => {
|
||||
const request = new Request('http://localhost/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('Name is required');
|
||||
});
|
||||
|
||||
it('returns 400 when name is empty string', async () => {
|
||||
const request = new Request('http://localhost/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: '' }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
377
__tests__/components/AddIngredientModal.test.tsx
Normal file
377
__tests__/components/AddIngredientModal.test.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AddIngredientModal from '@/app/dashboard/ingredients/AddIngredientModal';
|
||||
|
||||
const mockCategories = [
|
||||
{ _id: 'cat1', name: 'Dairy' },
|
||||
{ _id: 'cat2', name: 'Vegetables' },
|
||||
];
|
||||
const mockSubcategories = [
|
||||
{ _id: 'sub1', name: 'Fresh', categoryId: 'cat1' },
|
||||
{ _id: 'sub2', name: 'Frozen', categoryId: 'cat2' },
|
||||
];
|
||||
const mockSuppliers = [
|
||||
{ _id: 'sup1', name: 'Acme Foods' },
|
||||
];
|
||||
|
||||
function setupFetchMock(overrides: Record<string, unknown> = {}) {
|
||||
vi.spyOn(global, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as RequestInfo).toString();
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (/\/api\/ingredients\/[a-zA-Z0-9]+$/.test(url) && method === 'GET') {
|
||||
return {
|
||||
ok: true,
|
||||
json: () => Promise.resolve(overrides.ingredient ?? {}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/ingredients') || /\/api\/ingredients\/[a-zA-Z0-9]+$/.test(url)) {
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
return {
|
||||
ok: overrides.saveOk !== false,
|
||||
status: overrides.saveOk === false ? 400 : 201,
|
||||
json: () => Promise.resolve(overrides.saveResponse ?? { _id: 'new-id' }),
|
||||
} as Response;
|
||||
}
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/categories')) {
|
||||
return { ok: true, json: () => Promise.resolve(overrides.categories ?? mockCategories) } as Response;
|
||||
}
|
||||
if (url.endsWith('/api/subcategories')) {
|
||||
return { ok: true, json: () => Promise.resolve(overrides.subcategories ?? mockSubcategories) } as Response;
|
||||
}
|
||||
if (url.endsWith('/api/suppliers')) {
|
||||
return { ok: true, json: () => Promise.resolve(overrides.suppliers ?? mockSuppliers) } as Response;
|
||||
}
|
||||
|
||||
return { ok: true, json: () => Promise.resolve({}) } as Response;
|
||||
});
|
||||
}
|
||||
|
||||
function getInputByLabel(container: HTMLElement, labelText: string): HTMLInputElement {
|
||||
const labels = container.querySelectorAll('label');
|
||||
for (const label of labels) {
|
||||
if (label.textContent?.trim() === labelText) {
|
||||
const input = label.parentElement?.querySelector('input, select, textarea');
|
||||
if (input) return input as HTMLInputElement;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find input for label "${labelText}"`);
|
||||
}
|
||||
|
||||
async function fillRequiredFields(container: HTMLElement, user: ReturnType<typeof userEvent.setup>) {
|
||||
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter');
|
||||
await user.type(nameInput, 'Test Ingredient');
|
||||
|
||||
const categorySelect = within(container).getByDisplayValue('Select category\u2026');
|
||||
fireEvent.change(categorySelect, { target: { value: 'cat1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const subSelects = within(container).getAllByDisplayValue('Select subcategory\u2026');
|
||||
expect(subSelects[0]).not.toBeDisabled();
|
||||
});
|
||||
const subSelect = within(container).getAllByDisplayValue('Select subcategory\u2026')[0];
|
||||
fireEvent.change(subSelect, { target: { value: 'sub1' } });
|
||||
|
||||
const quantityInput = getInputByLabel(container, 'Quantity');
|
||||
await user.type(quantityInput, '5');
|
||||
|
||||
const unitInput = within(container).getByPlaceholderText('kg, L, pcs\u2026');
|
||||
await user.type(unitInput, 'kg');
|
||||
|
||||
const grossInput = getInputByLabel(container, 'Gross Price (\u20AC)');
|
||||
await user.type(grossInput, '10');
|
||||
|
||||
const supplierSelect = within(container).getByDisplayValue('Select supplier\u2026');
|
||||
fireEvent.change(supplierSelect, { target: { value: 'sup1' } });
|
||||
}
|
||||
|
||||
describe('AddIngredientModal', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders nothing when open=false', () => {
|
||||
setupFetchMock();
|
||||
const { container } = render(
|
||||
<AddIngredientModal open={false} onClose={vi.fn()} onSaved={vi.fn()} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('loads reference data on open', async () => {
|
||||
setupFetchMock();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows title "Add Ingredient" in add mode', () => {
|
||||
setupFetchMock();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
expect(within(container).getByText('Add Ingredient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows title "Edit Ingredient" in edit mode', () => {
|
||||
setupFetchMock({
|
||||
ingredient: {
|
||||
_id: 'ing1',
|
||||
name: 'Butter',
|
||||
categoryId: 'cat1',
|
||||
subcategoryId: 'sub1',
|
||||
quantity: 10,
|
||||
unit: 'kg',
|
||||
unitPrice: 5,
|
||||
vat: 9,
|
||||
supplierId: 'sup1',
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} ingredientId="ing1" />);
|
||||
expect(within(container).getByText('Edit Ingredient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error for empty name', async () => {
|
||||
setupFetchMock();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Ingredient name is required.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for no category', async () => {
|
||||
setupFetchMock();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter');
|
||||
await user.type(nameInput, 'Test');
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Please select a category.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for no subcategory', async () => {
|
||||
setupFetchMock();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter');
|
||||
await user.type(nameInput, 'Test');
|
||||
|
||||
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Please select a subcategory.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for quantity <= 0', async () => {
|
||||
setupFetchMock();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
|
||||
|
||||
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Quantity must be greater than 0.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for no unit', async () => {
|
||||
setupFetchMock();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
|
||||
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
|
||||
|
||||
await user.type(getInputByLabel(container, 'Quantity'), '5');
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Unit is required.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for gross price <= 0', async () => {
|
||||
setupFetchMock();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
|
||||
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
|
||||
|
||||
await user.type(getInputByLabel(container, 'Quantity'), '5');
|
||||
await user.type(within(container).getByPlaceholderText('kg, L, pcs\u2026'), 'kg');
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Gross price must be greater than 0.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for no supplier', async () => {
|
||||
setupFetchMock();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(within(container).getByPlaceholderText('e.g. Organic Butter'), 'Test');
|
||||
fireEvent.change(within(container).getByDisplayValue('Select category\u2026'), { target: { value: 'cat1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getAllByDisplayValue('Select subcategory\u2026')[0]).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.change(within(container).getAllByDisplayValue('Select subcategory\u2026')[0], { target: { value: 'sub1' } });
|
||||
|
||||
await user.type(getInputByLabel(container, 'Quantity'), '5');
|
||||
await user.type(within(container).getByPlaceholderText('kg, L, pcs\u2026'), 'kg');
|
||||
await user.type(getInputByLabel(container, 'Gross Price (\u20AC)'), '10');
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Please select a supplier.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSaved and onClose on successful save', async () => {
|
||||
setupFetchMock();
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
const { container } = render(<AddIngredientModal open={true} onClose={onClose} onSaved={onSaved} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await fillRequiredFields(container, user);
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledOnce();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API error on failed save', async () => {
|
||||
setupFetchMock({
|
||||
saveOk: false,
|
||||
saveResponse: { error: 'Server error occurred' },
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Dairy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await fillRequiredFields(container, user);
|
||||
|
||||
fireEvent.click(within(container).getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Server error occurred')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches and populates form in edit mode', async () => {
|
||||
setupFetchMock({
|
||||
ingredient: {
|
||||
_id: 'ing1',
|
||||
name: 'Butter',
|
||||
categoryId: 'cat1',
|
||||
subcategoryId: 'sub1',
|
||||
quantity: 10,
|
||||
unit: 'kg',
|
||||
unitPrice: 5,
|
||||
vat: 9,
|
||||
supplierId: 'sup1',
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<AddIngredientModal {...defaultProps} ingredientId="ing1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
const nameInput = within(container).getByPlaceholderText('e.g. Organic Butter') as HTMLInputElement;
|
||||
expect(nameInput.value).toBe('Butter');
|
||||
});
|
||||
});
|
||||
});
|
||||
102
__tests__/components/DeleteConfirmModal.test.tsx
Normal file
102
__tests__/components/DeleteConfirmModal.test.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/react';
|
||||
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
|
||||
|
||||
describe('DeleteConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
ingredientName: 'Organic Butter',
|
||||
onClose: vi.fn(),
|
||||
onConfirm: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders nothing when open=false', () => {
|
||||
const { container } = render(
|
||||
<DeleteConfirmModal {...defaultProps} open={false} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows ingredient name in confirmation text', () => {
|
||||
const { container } = render(<DeleteConfirmModal {...defaultProps} />);
|
||||
expect(within(container).getByText('Organic Butter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm when Delete clicked', async () => {
|
||||
const { container } = render(<DeleteConfirmModal {...defaultProps} />);
|
||||
const buttons = within(container).getAllByRole('button');
|
||||
const deleteBtn = buttons.find(b => b.textContent === 'Delete')!;
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Deleting..." during async operation', async () => {
|
||||
let resolveDelete!: () => void;
|
||||
const slowConfirm = vi.fn(() => new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve;
|
||||
}));
|
||||
|
||||
const { container } = render(<DeleteConfirmModal {...defaultProps} onConfirm={slowConfirm} />);
|
||||
const buttons = within(container).getAllByRole('button');
|
||||
const deleteBtn = buttons.find(b => b.textContent === 'Delete')!;
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const btns = within(container).getAllByRole('button');
|
||||
expect(btns.find(b => b.textContent === 'Deleting...')).toBeDefined();
|
||||
});
|
||||
|
||||
resolveDelete();
|
||||
await waitFor(() => {
|
||||
const btns = within(container).getAllByRole('button');
|
||||
expect(btns.find(b => b.textContent === 'Delete')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables both buttons during deletion', async () => {
|
||||
let resolveDelete!: () => void;
|
||||
const slowConfirm = vi.fn(() => new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve;
|
||||
}));
|
||||
|
||||
const { container } = render(<DeleteConfirmModal {...defaultProps} onConfirm={slowConfirm} />);
|
||||
const buttons = within(container).getAllByRole('button');
|
||||
const deleteBtn = buttons.find(b => b.textContent === 'Delete')!;
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const btns = within(container).getAllByRole('button');
|
||||
const cancelBtn = btns.find(b => b.textContent === 'Cancel')!;
|
||||
const deletingBtn = btns.find(b => b.textContent === 'Deleting...')!;
|
||||
expect(cancelBtn).toBeDisabled();
|
||||
expect(deletingBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveDelete();
|
||||
await waitFor(() => {
|
||||
const btns = within(container).getAllByRole('button');
|
||||
const cancelBtn = btns.find(b => b.textContent === 'Cancel')!;
|
||||
expect(cancelBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel clicked', () => {
|
||||
const { container } = render(<DeleteConfirmModal {...defaultProps} />);
|
||||
const buttons = within(container).getAllByRole('button');
|
||||
const cancelBtn = buttons.find(b => b.textContent === 'Cancel')!;
|
||||
fireEvent.click(cancelBtn);
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
120
__tests__/components/IngredientTable.test.tsx
Normal file
120
__tests__/components/IngredientTable.test.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import IngredientTable, { type Ingredient } from '@/app/dashboard/ingredients/IngredientTable';
|
||||
|
||||
const makeIngredient = (overrides: Partial<Ingredient> = {}): Ingredient => ({
|
||||
_id: 'ing1',
|
||||
code: 'ING-001',
|
||||
name: 'Butter',
|
||||
category: 'Dairy',
|
||||
subcategory: 'Fresh',
|
||||
quantity: 10,
|
||||
unit: 'kg',
|
||||
unitPrice: 5.00,
|
||||
vat: 9,
|
||||
supplier: 'Acme',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('IngredientTable', () => {
|
||||
const defaultProps = {
|
||||
ingredients: [makeIngredient()],
|
||||
onView: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders ingredient rows with correct data', () => {
|
||||
render(<IngredientTable {...defaultProps} />);
|
||||
|
||||
expect(screen.getAllByText('Butter').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/ING-001/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calculates net price correctly (unitPrice * (1 + vat/100))', () => {
|
||||
// unitPrice=5.00, vat=9 → net = 5.00 * 1.09 = 5.45
|
||||
render(<IngredientTable {...defaultProps} />);
|
||||
|
||||
const netPriceElements = screen.getAllByText(/5\.45/);
|
||||
expect(netPriceElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('toggles individual ingredient selection', () => {
|
||||
render(<IngredientTable {...defaultProps} />);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||
// Find an unchecked ingredient checkbox (not the select-all header)
|
||||
const ingredientCheckbox = checkboxes.find(cb => !cb.checked)!;
|
||||
|
||||
expect(ingredientCheckbox.checked).toBe(false);
|
||||
fireEvent.click(ingredientCheckbox);
|
||||
expect(ingredientCheckbox.checked).toBe(true);
|
||||
fireEvent.click(ingredientCheckbox);
|
||||
expect(ingredientCheckbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles all ingredients', () => {
|
||||
const ingredients = [
|
||||
makeIngredient({ _id: 'ing1', name: 'Butter' }),
|
||||
makeIngredient({ _id: 'ing2', name: 'Milk' }),
|
||||
];
|
||||
const { container } = render(
|
||||
<IngredientTable {...defaultProps} ingredients={ingredients} />
|
||||
);
|
||||
|
||||
// Get the select-all checkbox from the desktop table header (thead)
|
||||
const thead = container.querySelector('thead');
|
||||
const selectAllCheckbox = thead!.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
|
||||
// Select all
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
expect(selectAllCheckbox.checked).toBe(true);
|
||||
|
||||
// Verify ingredient checkboxes in tbody are also checked
|
||||
const tbody = container.querySelector('tbody');
|
||||
const tbodyCheckboxes = tbody!.querySelectorAll('input[type="checkbox"]') as NodeListOf<HTMLInputElement>;
|
||||
tbodyCheckboxes.forEach(cb => expect(cb.checked).toBe(true));
|
||||
|
||||
// Deselect all
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
expect(selectAllCheckbox.checked).toBe(false);
|
||||
tbodyCheckboxes.forEach(cb => expect(cb.checked).toBe(false));
|
||||
});
|
||||
|
||||
it('renders empty when ingredients list is empty', () => {
|
||||
const { container } = render(
|
||||
<IngredientTable {...defaultProps} ingredients={[]} />
|
||||
);
|
||||
|
||||
const tbody = container.querySelector('tbody');
|
||||
expect(tbody?.children.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates net price with 0% VAT', () => {
|
||||
const ingredients = [makeIngredient({ unitPrice: 10.00, vat: 0 })];
|
||||
render(<IngredientTable {...defaultProps} ingredients={ingredients} />);
|
||||
|
||||
// net = 10.00 * 1.0 = 10.00
|
||||
const netPriceElements = screen.getAllByText(/10\.00/);
|
||||
expect(netPriceElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calculates net price with high VAT', () => {
|
||||
const ingredients = [makeIngredient({ unitPrice: 10.00, vat: 25 })];
|
||||
render(<IngredientTable {...defaultProps} ingredients={ingredients} />);
|
||||
|
||||
// net = 10.00 * 1.25 = 12.50
|
||||
const netPriceElements = screen.getAllByText(/12\.50/);
|
||||
expect(netPriceElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
163
__tests__/components/ViewIngredientModal.test.tsx
Normal file
163
__tests__/components/ViewIngredientModal.test.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
|
||||
import ViewIngredientModal from '@/app/dashboard/ingredients/ViewIngredientModal';
|
||||
|
||||
const baseIngredient = {
|
||||
_id: 'ing1',
|
||||
code: 'ING-001',
|
||||
name: 'Organic Butter',
|
||||
category: 'Dairy',
|
||||
subcategory: 'Fresh',
|
||||
quantity: 10,
|
||||
unit: 'kg',
|
||||
unitPrice: 5.00,
|
||||
vat: 9,
|
||||
supplier: 'Acme Foods',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
function mockFetch(data: unknown) {
|
||||
return vi.spyOn(global, 'fetch').mockResolvedValue({
|
||||
json: () => Promise.resolve(data),
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe('ViewIngredientModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders nothing when open=false', () => {
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={false} ingredientId="ing1" onClose={vi.fn()} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading spinner then ingredient data', async () => {
|
||||
mockFetch(baseIngredient);
|
||||
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={true} ingredientId="ing1" onClose={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(within(container).getByText('Loading...')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Organic Butter')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays pricing section (gross, VAT, net price)', async () => {
|
||||
mockFetch(baseIngredient);
|
||||
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={true} ingredientId="ing1" onClose={vi.fn()} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Pricing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Gross: €5.00
|
||||
expect(within(container).getByText(/€5\.00/)).toBeInTheDocument();
|
||||
// VAT: rendered as {vat}% — separate text nodes so use regex
|
||||
expect(within(container).getByText(/9%/)).toBeInTheDocument();
|
||||
// Net: 5.00 * 1.09 = 5.45
|
||||
expect(within(container).getByText(/€5\.45/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows discount section only when discountValue > 0', async () => {
|
||||
const withDiscount = {
|
||||
...baseIngredient,
|
||||
discountType: 'percent' as const,
|
||||
discountValue: 10,
|
||||
applyDiscountToNet: false,
|
||||
};
|
||||
mockFetch(withDiscount);
|
||||
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={true} ingredientId="ing1" onClose={vi.fn()} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Discount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(within(container).getByText('Percentage')).toBeInTheDocument();
|
||||
expect(within(container).getByText('10%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides discount section when not present', async () => {
|
||||
mockFetch(baseIngredient);
|
||||
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={true} ingredientId="ing1" onClose={vi.fn()} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Organic Butter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(within(container).queryByText('Discount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows advanced section only when advanced fields present', async () => {
|
||||
const withAdvanced = {
|
||||
...baseIngredient,
|
||||
minStockLevel: 5,
|
||||
storageInstructions: 'Keep refrigerated',
|
||||
shelfLifeDays: 30,
|
||||
notes: 'Premium quality',
|
||||
};
|
||||
mockFetch(withAdvanced);
|
||||
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={true} ingredientId="ing1" onClose={vi.fn()} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Additional Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(within(container).getByText('Keep refrigerated')).toBeInTheDocument();
|
||||
expect(within(container).getByText('30 days')).toBeInTheDocument();
|
||||
expect(within(container).getByText('Premium quality')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides advanced section when not present', async () => {
|
||||
mockFetch(baseIngredient);
|
||||
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={true} ingredientId="ing1" onClose={vi.fn()} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Organic Butter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(within(container).queryByText('Additional Details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose on close button', async () => {
|
||||
mockFetch(baseIngredient);
|
||||
const onClose = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<ViewIngredientModal open={true} ingredientId="ing1" onClose={onClose} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(container).getByText('Organic Butter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(within(container).getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
2483
package-lock.json
generated
2483
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -6,7 +6,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"mongodb": "^7.1.0",
|
||||
@ -16,12 +17,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^27.0.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
},
|
||||
});
|
||||
1
vitest.setup.ts
Normal file
1
vitest.setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
Loading…
Reference in New Issue
Block a user